This commit is contained in:
2025-12-16 15:42:38 +01:00
parent 29410708c8
commit 362894d1a5
147 changed files with 780 additions and 375 deletions

View File

@@ -0,0 +1,29 @@
import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
import type { Response } from 'express';
import type { RecordPageViewInput, RecordPageViewOutput, RecordEngagementInput, RecordEngagementOutput } from './dto/AnalyticsDto';
import { AnalyticsService } from './AnalyticsService';
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
) {}
@Post('page-view')
async recordPageView(
@Body() input: RecordPageViewInput,
@Res() res: Response,
): Promise<void> {
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(output);
}
@Post('engagement')
async recordEngagement(
@Body() input: RecordEngagementInput,
@Res() res: Response,
): Promise<void> {
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(output);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
import { AnalyticsProviders } from './AnalyticsProviders';
@Module({
imports: [],
controllers: [AnalyticsController],
providers: AnalyticsProviders,
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,44 @@
import { Provider } from '@nestjs/common';
import { AnalyticsService } from './AnalyticsService';
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
const Logger_TOKEN = 'Logger_TOKEN';
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
import type { Logger } from '@core/shared/application';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
export const AnalyticsProviders: Provider[] = [
AnalyticsService,
RecordPageViewUseCase,
RecordEngagementUseCase,
{
provide: Logger_TOKEN,
useClass: ConsoleLogger,
},
{
provide: IPAGE_VIEW_REPO_TOKEN,
useClass: InMemoryPageViewRepository,
},
{
provide: IENGAGEMENT_REPO_TOKEN,
useClass: InMemoryEngagementRepository,
},
{
provide: RECORD_PAGE_VIEW_USE_CASE_TOKEN,
useClass: RecordPageViewUseCase,
},
{
provide: RECORD_ENGAGEMENT_USE_CASE_TOKEN,
useClass: RecordEngagementUseCase,
},
];

View File

@@ -0,0 +1,26 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecordEngagementInput, RecordEngagementOutput, RecordPageViewInput, RecordPageViewOutput } from './dto/AnalyticsDto';
import type { Logger } from '@core/shared/application';
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
const Logger_TOKEN = 'Logger_TOKEN';
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
@Injectable()
export class AnalyticsService {
constructor(
@Inject(RECORD_PAGE_VIEW_USE_CASE_TOKEN) private readonly recordPageViewUseCase: RecordPageViewUseCase,
@Inject(RECORD_ENGAGEMENT_USE_CASE_TOKEN) private readonly recordEngagementUseCase: RecordEngagementUseCase,
@Inject(Logger_TOKEN) private readonly logger: Logger,
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
return await this.recordPageViewUseCase.execute(input);
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
return await this.recordEngagementUseCase.execute(input);
}
}

View File

@@ -0,0 +1,127 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum, IsBoolean, IsNumber, IsObject } from 'class-validator';
// From core/analytics/domain/types/PageView.ts
export enum EntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
}
// From core/analytics/domain/types/PageView.ts
export enum VisitorType {
ANONYMOUS = 'anonymous',
DRIVER = 'driver',
SPONSOR = 'sponsor',
}
export class RecordPageViewInput {
@ApiProperty({ enum: EntityType })
@IsEnum(EntityType)
entityType!: EntityType;
@ApiProperty()
@IsString()
entityId!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
visitorId?: string;
@ApiProperty({ enum: VisitorType })
@IsEnum(VisitorType)
visitorType!: VisitorType;
@ApiProperty()
@IsString()
sessionId!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
referrer?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
userAgent?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
country?: string;
}
export class RecordPageViewOutput {
@ApiProperty()
@IsString()
pageViewId!: string;
}
// From core/analytics/domain/types/EngagementEvent.ts
export enum EngagementAction {
CLICK_SPONSOR_LOGO = 'click_sponsor_logo',
CLICK_SPONSOR_URL = 'click_sponsor_url',
DOWNLOAD_LIVERY_PACK = 'download_livery_pack',
JOIN_LEAGUE = 'join_league',
REGISTER_RACE = 'register_race',
VIEW_STANDINGS = 'view_standings',
VIEW_SCHEDULE = 'view_schedule',
SHARE_SOCIAL = 'share_social',
CONTACT_SPONSOR = 'contact_sponsor',
}
// From core/analytics/domain/types/EngagementEvent.ts
export enum EngagementEntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
SPONSORSHIP = 'sponsorship',
}
export class RecordEngagementInput {
@ApiProperty({ enum: EngagementAction })
@IsEnum(EngagementAction)
action!: EngagementAction;
@ApiProperty({ enum: EngagementEntityType })
@IsEnum(EngagementEntityType)
entityType!: EngagementEntityType;
@ApiProperty()
@IsString()
entityId!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
actorId?: string;
@ApiProperty({ enum: ['anonymous', 'driver', 'sponsor'] })
@IsEnum(['anonymous', 'driver', 'sponsor'])
actorType!: 'anonymous' | 'driver' | 'sponsor';
@ApiProperty()
@IsString()
sessionId!: string;
@ApiProperty({ required: false, type: Object })
@IsOptional()
@IsObject()
metadata?: Record<string, string | number | boolean>;
}
export class RecordEngagementOutput {
@ApiProperty()
@IsString()
eventId!: string;
@ApiProperty()
@IsNumber()
engagementWeight!: number;
}

View File

@@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordEngagementUseCase } from './RecordEngagementUseCase';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { Logger } from '@core/shared/application';
describe('RecordEngagementUseCase', () => {
let useCase: RecordEngagementUseCase;
let engagementRepository: jest.Mocked<IEngagementRepository>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordEngagementUseCase,
{
provide: 'IEngagementRepository_TOKEN',
useValue: {
save: jest.fn(),
},
},
{
provide: 'Logger_TOKEN',
useValue: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
engagementRepository = module.get('IEngagementRepository_TOKEN');
logger = module.get('Logger_TOKEN');
});
describe('execute', () => {
it('should save the engagement event and return the eventId and engagementWeight', async () => {
const input = {
action: 'like' as any,
entityType: 'race' as any,
entityId: 'race-123',
actorType: 'driver',
sessionId: 'session-456',
actorId: 'actor-789',
metadata: { some: 'data' },
};
const mockEvent = {
getEngagementWeight: jest.fn().mockReturnValue(10),
};
// Mock the create function to return the mock event
const originalCreate = require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create;
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = jest.fn().mockReturnValue(mockEvent);
engagementRepository.save.mockResolvedValue(undefined);
const result = await useCase.execute(input);
expect(logger.debug).toHaveBeenCalledWith('Executing RecordEngagementUseCase', { input });
expect(engagementRepository.save).toHaveBeenCalledWith(mockEvent);
expect(logger.info).toHaveBeenCalledWith('Engagement recorded successfully', expect.objectContaining({ eventId: expect.any(String), input }));
expect(result).toHaveProperty('eventId');
expect(result).toHaveProperty('engagementWeight', 10);
expect(typeof result.eventId).toBe('string');
// Restore original
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = originalCreate;
});
it('should handle errors and throw them', async () => {
const input = {
action: 'like' as any,
entityType: 'race' as any,
entityId: 'race-123',
actorType: 'driver',
sessionId: 'session-456',
};
const error = new Error('Save failed');
engagementRepository.save.mockRejectedValue(error);
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
expect(logger.error).toHaveBeenCalledWith('Error recording engagement', error, { input });
});
});
});

View File

@@ -0,0 +1,49 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecordEngagementInput, RecordEngagementOutput } from '../dto/AnalyticsDto';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { Logger } from '@core/shared/application';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
const Logger_TOKEN = 'Logger_TOKEN';
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
@Injectable()
export class RecordEngagementUseCase {
constructor(
@Inject(IENGAGEMENT_REPO_TOKEN) private readonly engagementRepository: IEngagementRepository,
@Inject(Logger_TOKEN) private readonly logger: Logger,
) {}
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
this.logger.debug('Executing RecordEngagementUseCase', { input });
try {
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
id: eventId,
action: input.action as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
entityType: input.entityType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
entityId: input.entityId,
actorType: input.actorType,
sessionId: input.sessionId,
};
const event = EngagementEvent.create({
...baseProps,
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
});
await this.engagementRepository.save(event);
this.logger.info('Engagement recorded successfully', { eventId, input });
return {
eventId,
engagementWeight: event.getEngagementWeight(),
};
} catch (error) {
this.logger.error('Error recording engagement', error, { input });
throw error;
}
}
}

View File

@@ -0,0 +1,76 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordPageViewUseCase } from './RecordPageViewUseCase';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger } from '@core/shared/application';
describe('RecordPageViewUseCase', () => {
let useCase: RecordPageViewUseCase;
let pageViewRepository: jest.Mocked<IPageViewRepository>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPageViewUseCase,
{
provide: 'IPageViewRepository_TOKEN',
useValue: {
save: jest.fn(),
},
},
{
provide: 'Logger_TOKEN',
useValue: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
pageViewRepository = module.get('IPageViewRepository_TOKEN');
logger = module.get('Logger_TOKEN');
});
describe('execute', () => {
it('should save the page view and return the pageViewId', async () => {
const input = {
entityType: 'race' as any,
entityId: 'race-123',
visitorType: 'anonymous' as any,
sessionId: 'session-456',
visitorId: 'visitor-789',
referrer: 'https://example.com',
userAgent: 'Mozilla/5.0',
country: 'US',
};
pageViewRepository.save.mockResolvedValue(undefined);
const result = await useCase.execute(input);
expect(logger.debug).toHaveBeenCalledWith('Executing RecordPageViewUseCase', { input });
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('Page view recorded successfully', expect.objectContaining({ pageViewId: expect.any(String), input }));
expect(result).toHaveProperty('pageViewId');
expect(typeof result.pageViewId).toBe('string');
});
it('should handle errors and throw them', async () => {
const input = {
entityType: 'race' as any,
entityId: 'race-123',
visitorType: 'anonymous' as any,
sessionId: 'session-456',
};
const error = new Error('Save failed');
pageViewRepository.save.mockRejectedValue(error);
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
expect(logger.error).toHaveBeenCalledWith('Error recording page view', error, { input });
});
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecordPageViewInput, RecordPageViewOutput } from '../dto/AnalyticsDto';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger } from '@core/shared/application';
import { PageView } from '@core/analytics/domain/entities/PageView';
const Logger_TOKEN = 'Logger_TOKEN';
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
@Injectable()
export class RecordPageViewUseCase {
constructor(
@Inject(IPAGE_VIEW_REPO_TOKEN) private readonly pageViewRepository: IPageViewRepository,
@Inject(Logger_TOKEN) private readonly logger: Logger,
) {}
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
this.logger.debug('Executing RecordPageViewUseCase', { input });
try {
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
id: pageViewId,
entityType: input.entityType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
entityId: input.entityId,
visitorType: input.visitorType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
sessionId: input.sessionId,
};
const pageView = PageView.create({
...baseProps,
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
...(input.country !== undefined ? { country: input.country } : {}),
});
await this.pageViewRepository.save(pageView);
this.logger.info('Page view recorded successfully', { pageViewId, input });
return { pageViewId };
} catch (error) {
this.logger.error('Error recording page view', error, { input });
throw error;
}
}
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Post, Body, Query, Res, Redirect, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './AuthService';
import { LoginParams, SignupParams, LoginWithIracingCallbackParams } from './dto/AuthDto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('signup')
async signup(@Body() params: SignupParams) {
return this.authService.signupWithEmail(params);
}
@Post('login')
async login(@Body() params: LoginParams) {
return this.authService.loginWithEmail(params);
}
@Get('session')
async getSession() {
return this.authService.getCurrentSession();
}
@Post('logout')
async logout() {
return this.authService.logout();
}
@Get('iracing/start')
async startIracingAuthRedirect(@Query('returnTo') returnTo?: string, @Res() res?: Response) {
const { redirectUrl, state } = await this.authService.startIracingAuthRedirect(returnTo);
// In real application, you might want to store 'state' in a secure cookie or session.
// For this example, we'll just redirect.
res.redirect(HttpStatus.FOUND, redirectUrl);
}
@Get('iracing/callback')
async loginWithIracingCallback(@Query('code') code: string, @Query('state') state: string, @Query('returnTo') returnTo?: string) {
return this.authService.loginWithIracingCallback({ code, state, returnTo });
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthService } from './AuthService';
import { AuthController } from './AuthController';
import { AuthProviders } from './AuthProviders';
@Module({
controllers: [AuthController],
providers: AuthProviders,
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,64 @@
import { Provider } from '@nestjs/common';
import { AuthService } from './AuthService';
// Import interfaces and concrete implementations
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import { IUserRepository, StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from '@core/shared/application';
import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository';
import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository';
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter';
// Define the tokens for dependency injection
export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository';
export const USER_REPOSITORY_TOKEN = 'IUserRepository';
export const PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService';
export const LOGGER_TOKEN = 'Logger';
export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
export const AuthProviders: Provider[] = [
AuthService, // Provide the service itself
{
provide: AUTH_REPOSITORY_TOKEN,
useFactory: (userRepository: IUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) => {
// Seed initial users for InMemoryUserRepository
const initialUsers: StoredUser[] = [
// Example user (replace with actual test users as needed)
{
id: 'user-1',
email: 'test@example.com',
passwordHash: 'demo_salt_moc.elpmaxe@tset', // "test@example.com" reversed
displayName: 'Test User',
salt: '', // Handled by hashing service
createdAt: new Date(),
},
];
const inMemoryUserRepository = new InMemoryUserRepository(logger, initialUsers);
return new InMemoryAuthRepository(inMemoryUserRepository, passwordHashingService, logger);
},
inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: USER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryUserRepository(logger), // Factory for InMemoryUserRepository
inject: [LOGGER_TOKEN],
},
{
provide: PASSWORD_HASHING_SERVICE_TOKEN,
useClass: InMemoryPasswordHashingService,
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
{
provide: IDENTITY_SESSION_PORT_TOKEN,
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,140 @@
import { Injectable, Inject, InternalServerErrorException } from '@nestjs/common';
import type { AuthenticatedUserDTO, AuthSessionDTO, SignupParams, LoginParams, IracingAuthRedirectResult, LoginWithIracingCallbackParams } from './dto/AuthDto';
// Core Use Cases
import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase';
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
import { GetCurrentSessionUseCase } from '@core/identity/application/use-cases/GetCurrentSessionUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
import { StartIracingAuthRedirectUseCase } from '@core/identity/application/use-cases/StartIracingAuthRedirectUseCase';
import { LoginWithIracingCallbackUseCase } from '@core/identity/application/use-cases/LoginWithIracingCallbackUseCase';
// Core Interfaces and Tokens
import { AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, IDENTITY_SESSION_PORT_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from "@core/shared/application";
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { UserId } from '@core/identity/domain/value-objects/UserId';
import { User } from '@core/identity/domain/entities/User';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO';
@Injectable()
export class AuthService {
private readonly loginUseCase: LoginUseCase;
private readonly signupUseCase: SignupUseCase;
private readonly getCurrentSessionUseCase: GetCurrentSessionUseCase;
private readonly logoutUseCase: LogoutUseCase;
private readonly startIracingAuthRedirectUseCase: StartIracingAuthRedirectUseCase;
private readonly loginWithIracingCallbackUseCase: LoginWithIracingCallbackUseCase;
constructor(
@Inject(AUTH_REPOSITORY_TOKEN) private authRepository: IAuthRepository,
@Inject(PASSWORD_HASHING_SERVICE_TOKEN) private passwordHashingService: IPasswordHashingService,
@Inject(LOGGER_TOKEN) private logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, // Inject IUserRepository here
) {
this.loginUseCase = new LoginUseCase(this.authRepository, this.passwordHashingService);
this.signupUseCase = new SignupUseCase(this.authRepository, this.passwordHashingService);
this.getCurrentSessionUseCase = new GetCurrentSessionUseCase(); // Doesn't have constructor parameters normally
this.logoutUseCase = new LogoutUseCase(this.identitySessionPort);
this.startIracingAuthRedirectUseCase = new StartIracingAuthRedirectUseCase();
this.loginWithIracingCallbackUseCase = new LoginWithIracingCallbackUseCase();
}
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
return {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName() ?? '',
// Map other fields as necessary
iracingCustomerId: user.getIracingCustomerId() ?? undefined,
primaryDriverId: user.getPrimaryDriverId() ?? undefined,
avatarUrl: user.getAvatarUrl() ?? undefined,
};
}
async getCurrentSession(): Promise<AuthSessionDTO | null> {
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) {
return null;
}
const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user
if (!user) {
// If session exists but user doesn't in DB, perhaps clear session?
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
await this.identitySessionPort.clearSession(); // Clear potentially stale session
return null;
}
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
return {
token: coreSession.token,
user: authenticatedUserDTO,
};
}
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
const user = await this.signupUseCase.execute(params.email, params.password, params.displayName);
// Create session after successful signup
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
return {
token: session.token,
user: authenticatedUserDTO,
};
}
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
try {
const user = await this.loginUseCase.execute(params.email, params.password);
// Create session after successful login
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
return {
token: session.token,
user: authenticatedUserDTO,
};
} 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.');
}
}
async startIracingAuthRedirect(returnTo?: string): Promise<IracingAuthRedirectResult> {
this.logger.debug('[AuthService] Starting iRacing auth redirect.');
// Note: The StartIracingAuthRedirectUseCase takes optional returnTo, but the DTO doesnt
const result = await this.startIracingAuthRedirectUseCase.execute(returnTo);
// Map core IracingAuthRedirectResult to AuthDto's IracingAuthRedirectResult
return { redirectUrl: result.redirectUrl, state: result.state };
}
async loginWithIracingCallback(params: LoginWithIracingCallbackParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Handling iRacing callback for code: ${params.code}`);
const user = await this.loginWithIracingCallbackUseCase.execute(params); // Pass params as is
// Create session after successful iRacing login
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
return {
token: session.token,
user: authenticatedUserDTO,
};
}
async logout(): Promise<void> {
this.logger.debug('[AuthService] Attempting logout.');
await this.logoutUseCase.execute();
}
}

View File

@@ -0,0 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthenticatedUserDTO {
@ApiProperty()
userId!: string;
@ApiProperty()
email!: string;
@ApiProperty()
displayName!: string;
}
export class AuthSessionDTO {
@ApiProperty()
token!: string;
@ApiProperty()
user!: AuthenticatedUserDTO;
}
export class SignupParams {
@ApiProperty()
email!: string;
@ApiProperty()
password!: string;
@ApiProperty()
displayName!: string;
@ApiProperty({ required: false })
iracingCustomerId?: string;
@ApiProperty({ required: false })
primaryDriverId?: string;
@ApiProperty({ required: false })
avatarUrl?: string;
}
export class LoginParams {
@ApiProperty()
email!: string;
@ApiProperty()
password!: string;
}
export class IracingAuthRedirectResult {
@ApiProperty()
redirectUrl!: string;
@ApiProperty()
state!: string;
}
export class LoginWithIracingCallbackParams {
@ApiProperty()
code!: string;
@ApiProperty()
state!: string;
@ApiProperty({ required: false })
returnTo?: string;
}

View File

@@ -0,0 +1,62 @@
import { Controller, Get, Post, Body, Req, Param } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { DriverService } from './DriverService';
import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel } from './dto/DriverDto';
@ApiTags('drivers')
@Controller('drivers')
export class DriverController {
constructor(private readonly driverService: DriverService) {}
@Get('leaderboard')
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardViewModel })
async getDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
return this.driverService.getDriversLeaderboard();
}
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDto })
async getTotalDrivers(): Promise<DriverStatsDto> {
return this.driverService.getTotalDrivers();
}
@Get('current')
@ApiOperation({ summary: 'Get current authenticated driver' })
@ApiResponse({ status: 200, description: 'Current driver data', type: DriverDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getCurrentDriver(@Req() req: Request): Promise<DriverDTO | null> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user']?.userId;
if (!userId) {
return null;
}
return this.driverService.getCurrentDriver(userId);
}
@Post('complete-onboarding')
@ApiOperation({ summary: 'Complete driver onboarding for a user' })
@ApiResponse({ status: 200, description: 'Onboarding complete', type: CompleteOnboardingOutput })
async completeOnboarding(
@Body() input: CompleteOnboardingInput,
@Req() req: Request,
): Promise<CompleteOnboardingOutput> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user'].userId; // Placeholder for actual user extraction
return this.driverService.completeOnboarding(userId, input);
}
@Get(':driverId/races/:raceId/registration-status')
@ApiOperation({ summary: 'Get driver registration status for a specific race' })
@ApiResponse({ status: 200, description: 'Driver registration status', type: DriverRegistrationStatusViewModel })
async getDriverRegistrationStatus(
@Param('driverId') driverId: string,
@Param('raceId') raceId: string,
): Promise<DriverRegistrationStatusViewModel> {
return this.driverService.getDriverRegistrationStatus({ driverId, raceId });
}
// Add other Driver endpoints here based on other presenters
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DriverService } from './DriverService';
import { DriverController } from './DriverController';
import { DriverProviders } from './DriverProviders';
@Module({
controllers: [DriverController],
providers: DriverProviders,
exports: [DriverService],
})
export class DriverModule {}

View File

@@ -0,0 +1,108 @@
import { Provider } from '@nestjs/common';
import { DriverService } from './DriverService';
// Import core interfaces
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IRankingService } from '@core/racing/domain/services/IRankingService';
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
import type { Logger } from "@gridpilot/core/shared/application";
// Import use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
// Import concrete in-memory implementations
import { InMemoryDriverRepository } from '../../..//racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRankingService } from '../../..//racing/services/InMemoryRankingService';
import { InMemoryDriverStatsService } from '../../..//racing/services/InMemoryDriverStatsService';
import { InMemoryDriverRatingProvider } from '../../..//racing/ports/InMemoryDriverRatingProvider';
import { InMemoryImageServiceAdapter } from '../../..//media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '../../..//racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '../../..//notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
import { ConsoleLogger } from '../../..//logging/ConsoleLogger';
// Define injection tokens
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RANKING_SERVICE_TOKEN = 'IRankingService';
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
// Use case tokens
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase';
export const DriverProviders: Provider[] = [
DriverService, // Provide the service itself
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), // Factory for InMemoryDriverRepository
inject: [LOGGER_TOKEN],
},
{
provide: RANKING_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryRankingService(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_STATS_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverStatsService(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_RATING_PROVIDER_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
inject: [LOGGER_TOKEN],
},
{
provide: IMAGE_SERVICE_PORT_TOKEN,
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REGISTRATION_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRegistrationRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryNotificationPreferenceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort) =>
new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new GetTotalDriversUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
},
{
provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new CompleteDriverOnboardingUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
useFactory: (registrationRepo: IRaceRegistrationRepository) => new IsDriverRegisteredForRaceUseCase(registrationRepo),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN],
},
];

View File

@@ -0,0 +1,187 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DriverService } from './DriverService';
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import type { Logger } from '@core/shared/application';
describe('DriverService', () => {
let service: DriverService;
let getDriversLeaderboardUseCase: jest.Mocked<GetDriversLeaderboardUseCase>;
let getTotalDriversUseCase: jest.Mocked<GetTotalDriversUseCase>;
let completeDriverOnboardingUseCase: jest.Mocked<CompleteDriverOnboardingUseCase>;
let isDriverRegisteredForRaceUseCase: jest.Mocked<IsDriverRegisteredForRaceUseCase>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DriverService,
{
provide: 'GetDriversLeaderboardUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'GetTotalDriversUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'CompleteDriverOnboardingUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'IsDriverRegisteredForRaceUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'Logger',
useValue: {
debug: jest.fn(),
},
},
],
}).compile();
service = module.get<DriverService>(DriverService);
getDriversLeaderboardUseCase = module.get('GetDriversLeaderboardUseCase');
getTotalDriversUseCase = module.get('GetTotalDriversUseCase');
completeDriverOnboardingUseCase = module.get('CompleteDriverOnboardingUseCase');
isDriverRegisteredForRaceUseCase = module.get('IsDriverRegisteredForRaceUseCase');
logger = module.get('Logger');
});
describe('getDriversLeaderboard', () => {
it('should call GetDriversLeaderboardUseCase and return the view model', async () => {
const mockViewModel = {
drivers: [
{
id: 'driver-1',
name: 'Driver 1',
rating: 2500,
skillLevel: 'Pro',
nationality: 'DE',
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
const mockPresenter = {
viewModel: mockViewModel,
};
getDriversLeaderboardUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getDriversLeaderboard();
expect(getDriversLeaderboardUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching drivers leaderboard.');
expect(result).toEqual(mockViewModel);
});
});
describe('getTotalDrivers', () => {
it('should call GetTotalDriversUseCase and return the view model', async () => {
const mockViewModel = { totalDrivers: 5 };
const mockPresenter = {
viewModel: mockViewModel,
};
getTotalDriversUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getTotalDrivers();
expect(getTotalDriversUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching total drivers count.');
expect(result).toEqual(mockViewModel);
});
});
describe('completeOnboarding', () => {
it('should call CompleteDriverOnboardingUseCase and return the view model', async () => {
const input = {
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
country: 'US',
timezone: 'America/New_York',
bio: 'Racing enthusiast',
};
const mockViewModel = {
success: true,
driverId: 'user-123',
};
const mockPresenter = {
viewModel: mockViewModel,
};
completeDriverOnboardingUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.completeOnboarding('user-123', input);
expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith(
{
userId: 'user-123',
...input,
},
expect.any(Object)
);
expect(logger.debug).toHaveBeenCalledWith('Completing onboarding for user:', 'user-123');
expect(result).toEqual(mockViewModel);
});
});
describe('getDriverRegistrationStatus', () => {
it('should call IsDriverRegisteredForRaceUseCase and return the view model', async () => {
const query = {
driverId: 'driver-1',
raceId: 'race-1',
};
const mockViewModel = {
isRegistered: true,
raceId: 'race-1',
driverId: 'driver-1',
};
const mockPresenter = {
viewModel: mockViewModel,
};
isDriverRegisteredForRaceUseCase.execute.mockImplementation(async (params, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getDriverRegistrationStatus(query);
expect(isDriverRegisteredForRaceUseCase.execute).toHaveBeenCalledWith(query, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('Checking driver registration status:', query);
expect(result).toEqual(mockViewModel);
});
});
});

View File

@@ -0,0 +1,69 @@
import { Injectable, Inject } from '@nestjs/common';
import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel } from './dto/DriverDto';
// Use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
// Presenters
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter';
import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter';
// Tokens
import { GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, GET_TOTAL_DRIVERS_USE_CASE_TOKEN, COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, LOGGER_TOKEN } from './DriverProviders';
import type { Logger } from '@core/shared/application';
@Injectable()
export class DriverService {
constructor(
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
const presenter = new DriversLeaderboardPresenter();
await this.getDriversLeaderboardUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async getTotalDrivers(): Promise<DriverStatsDto> {
this.logger.debug('[DriverService] Fetching total drivers count.');
const presenter = new DriverStatsPresenter();
await this.getTotalDriversUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async completeOnboarding(userId: string, input: CompleteOnboardingInput): Promise<CompleteOnboardingOutput> {
this.logger.debug('Completing onboarding for user:', userId);
const presenter = new CompleteOnboardingPresenter();
await this.completeDriverOnboardingUseCase.execute({
userId,
firstName: input.firstName,
lastName: input.lastName,
displayName: input.displayName,
country: input.country,
timezone: input.timezone,
bio: input.bio,
}, presenter);
return presenter.viewModel;
}
async getDriverRegistrationStatus(query: GetDriverRegistrationStatusQuery): Promise<DriverRegistrationStatusViewModel> {
this.logger.debug('Checking driver registration status:', query);
const presenter = new DriverRegistrationStatusPresenter();
await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId }, presenter);
return presenter.viewModel;
}
}

View File

@@ -0,0 +1,138 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class DriverLeaderboardItemViewModel {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
rating: number;
@ApiProperty()
skillLevel: string; // Assuming skillLevel is a string like 'Rookie', 'Pro', etc.
@ApiProperty()
nationality: string;
@ApiProperty()
racesCompleted: number;
@ApiProperty()
wins: number;
@ApiProperty()
podiums: number;
@ApiProperty()
isActive: boolean;
@ApiProperty()
rank: number;
@ApiProperty({ nullable: true })
avatarUrl?: string;
}
export class DriversLeaderboardViewModel {
@ApiProperty({ type: [DriverLeaderboardItemViewModel] })
drivers: DriverLeaderboardItemViewModel[];
@ApiProperty()
totalRaces: number;
@ApiProperty()
totalWins: number;
@ApiProperty()
activeCount: number;
}
export class DriverStatsDto {
@ApiProperty()
totalDrivers: number;
}
export class CompleteOnboardingInput {
@ApiProperty()
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
displayName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
country: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
timezone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
bio?: string;
}
export class CompleteOnboardingOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsString()
driverId?: string;
@ApiProperty({ required: false })
@IsString()
errorMessage?: string;
}
export class GetDriverRegistrationStatusQuery {
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsString()
driverId: string;
}
export class DriverRegistrationStatusViewModel {
@ApiProperty()
@IsBoolean()
isRegistered: boolean;
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsString()
driverId: string;
}
export class DriverDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string; // Display name or full name
}
// Add other DTOs for driver-related logic as needed

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CompleteOnboardingPresenter } from './CompleteOnboardingPresenter';
import type { CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter';
describe('CompleteOnboardingPresenter', () => {
let presenter: CompleteOnboardingPresenter;
beforeEach(() => {
presenter = new CompleteOnboardingPresenter();
});
describe('present', () => {
it('should map successful core DTO to API view model', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: true,
driverId: 'driver-123',
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
success: true,
driverId: 'driver-123',
errorMessage: undefined,
});
});
it('should map failed core DTO to API view model', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: false,
errorMessage: 'Driver already exists',
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
success: false,
driverId: undefined,
errorMessage: 'Driver already exists',
});
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: true,
driverId: 'driver-123',
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,23 @@
import { CompleteOnboardingOutput } from '../dto/DriverDto';
import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter';
export class CompleteOnboardingPresenter implements ICompleteDriverOnboardingPresenter {
private result: CompleteOnboardingOutput | null = null;
reset() {
this.result = null;
}
present(dto: CompleteDriverOnboardingResultDTO) {
this.result = {
success: dto.success,
driverId: dto.driverId,
errorMessage: dto.errorMessage,
};
}
get viewModel(): CompleteOnboardingOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriverRegistrationStatusPresenter } from './DriverRegistrationStatusPresenter';
describe('DriverRegistrationStatusPresenter', () => {
let presenter: DriverRegistrationStatusPresenter;
beforeEach(() => {
presenter = new DriverRegistrationStatusPresenter();
});
describe('present', () => {
it('should map parameters to view model for registered driver', () => {
presenter.present(true, 'race-123', 'driver-456');
const result = presenter.viewModel;
expect(result).toEqual({
isRegistered: true,
raceId: 'race-123',
driverId: 'driver-456',
});
});
it('should map parameters to view model for unregistered driver', () => {
presenter.present(false, 'race-789', 'driver-101');
const result = presenter.viewModel;
expect(result).toEqual({
isRegistered: false,
raceId: 'race-789',
driverId: 'driver-101',
});
});
});
describe('reset', () => {
it('should reset the result', () => {
presenter.present(true, 'race-123', 'driver-456');
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,28 @@
import { DriverRegistrationStatusViewModel } from '../dto/DriverDto';
import type { IDriverRegistrationStatusPresenter } from '../../../../../core/racing/application/presenters/IDriverRegistrationStatusPresenter';
export class DriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter {
private result: DriverRegistrationStatusViewModel | null = null;
present(isRegistered: boolean, raceId: string, driverId: string) {
this.result = {
isRegistered,
raceId,
driverId,
};
}
getViewModel(): DriverRegistrationStatusViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
// For consistency with other presenters
reset() {
this.result = null;
}
get viewModel(): DriverRegistrationStatusViewModel {
return this.getViewModel();
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriverStatsPresenter } from './DriverStatsPresenter';
import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
describe('DriverStatsPresenter', () => {
let presenter: DriverStatsPresenter;
beforeEach(() => {
presenter = new DriverStatsPresenter();
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: TotalDriversResultDTO = {
totalDrivers: 42,
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
totalDrivers: 42,
});
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: TotalDriversResultDTO = {
totalDrivers: 10,
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,21 @@
import { DriverStatsDto } from '../dto/DriverDto';
import type { ITotalDriversPresenter, TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
export class DriverStatsPresenter implements ITotalDriversPresenter {
private result: DriverStatsDto | null = null;
reset() {
this.result = null;
}
present(dto: TotalDriversResultDTO) {
this.result = {
totalDrivers: dto.totalDrivers,
};
}
get viewModel(): DriverStatsDto {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
beforeEach(() => {
presenter = new DriversLeaderboardPresenter();
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date('2023-01-01'),
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date('2023-01-02'),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
{ driverId: 'driver-2', rating: 2400, overallRank: 2 },
],
stats: {
'driver-1': { racesCompleted: 50, wins: 10, podiums: 20 },
'driver-2': { racesCompleted: 40, wins: 5, podiums: 15 },
},
avatarUrls: {
'driver-1': 'https://example.com/avatar1.png',
'driver-2': 'https://example.com/avatar2.png',
},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({
id: 'driver-1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
});
expect(result.drivers[1]).toEqual({
id: 'driver-2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'Pro',
nationality: 'DE',
racesCompleted: 40,
wins: 5,
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
});
expect(result.totalRaces).toBe(90);
expect(result.totalWins).toBe(15);
expect(result.activeCount).toBe(2);
});
it('should sort drivers by rating descending', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date(),
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date(),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2400, overallRank: 2 },
{ driverId: 'driver-2', rating: 2500, overallRank: 1 },
],
stats: {},
avatarUrls: {},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers[0].id).toBe('driver-2'); // Higher rating first
expect(result.drivers[1].id).toBe('driver-1');
});
it('should handle missing stats gracefully', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date(),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
],
stats: {}, // No stats
avatarUrls: {},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers[0].racesCompleted).toBe(0);
expect(result.drivers[0].wins).toBe(0);
expect(result.drivers[0].podiums).toBe(0);
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [],
rankings: [],
stats: {},
avatarUrls: {},
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,49 @@
import { DriversLeaderboardViewModel, DriverLeaderboardItemViewModel } from '../dto/DriverDto';
import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
private result: DriversLeaderboardViewModel | null = null;
reset() {
this.result = null;
}
present(dto: DriversLeaderboardResultDTO) {
const drivers: DriverLeaderboardItemViewModel[] = dto.drivers.map(driver => {
const ranking = dto.rankings.find(r => r.driverId === driver.id);
const stats = dto.stats[driver.id];
const avatarUrl = dto.avatarUrls[driver.id];
return {
id: driver.id,
name: driver.name,
rating: ranking?.rating ?? 0,
skillLevel: 'Pro', // TODO: map from domain
nationality: driver.country,
racesCompleted: stats?.racesCompleted ?? 0,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
isActive: true, // TODO: determine from domain
rank: ranking?.overallRank ?? 0,
avatarUrl,
};
});
// Calculate totals
const totalRaces = drivers.reduce((sum, d) => sum + (d.racesCompleted ?? 0), 0);
const totalWins = drivers.reduce((sum, d) => sum + (d.wins ?? 0), 0);
const activeCount = drivers.filter(d => d.isActive).length;
this.result = {
drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)),
totalRaces,
totalWins,
activeCount,
};
}
get viewModel(): DriversLeaderboardViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
import { LeagueProviders } from './LeagueProviders';
describe('LeagueController (integration)', () => {
let controller: LeagueController;
let service: LeagueService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LeagueController],
providers: [LeagueService, ...LeagueProviders],
}).compile();
controller = module.get<LeagueController>(LeagueController);
service = module.get<LeagueService>(LeagueService);
});
it('should get total leagues', async () => {
const result = await controller.getTotalLeagues();
expect(result).toHaveProperty('totalLeagues');
expect(typeof result.totalLeagues).toBe('number');
});
it('should get all leagues with capacity', async () => {
const result = await controller.getAllLeaguesWithCapacity();
expect(result).toHaveProperty('leagues');
expect(result).toHaveProperty('totalCount');
expect(Array.isArray(result.leagues)).toBe(true);
});
it('should get league standings', async () => {
try {
const result = await controller.getLeagueStandings('non-existent-league');
expect(result).toHaveProperty('standings');
expect(Array.isArray(result.standings)).toBe(true);
} catch (error) {
// Expected for non-existent league
expect(error.message).toContain('not found');
}
});
});

View File

@@ -0,0 +1,179 @@
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel, LeagueMembershipsViewModel, LeagueStandingsViewModel, LeagueScheduleViewModel, LeagueStatsViewModel, LeagueAdminViewModel, CreateLeagueInput, CreateLeagueOutput } from './dto/LeagueDto';
import { GetLeagueAdminPermissionsInput, GetLeagueJoinRequestsQuery, GetLeagueProtestsQuery, GetLeagueSeasonsQuery, GetLeagueAdminConfigQuery, GetLeagueOwnerSummaryQuery } from './dto/LeagueDto'; // Explicitly import queries
@ApiTags('leagues')
@Controller('leagues')
export class LeagueController {
constructor(private readonly leagueService: LeagueService) {}
@Get('all-with-capacity')
@ApiOperation({ summary: 'Get all leagues with their capacity information' })
@ApiResponse({ status: 200, description: 'List of leagues with capacity', type: AllLeaguesWithCapacityViewModel })
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Get('total-leagues')
@ApiOperation({ summary: 'Get the total number of leagues' })
@ApiResponse({ status: 200, description: 'Total number of leagues', type: LeagueStatsDto })
async getTotalLeagues(): Promise<LeagueStatsDto> {
return this.leagueService.getTotalLeagues();
}
@Get(':leagueId/join-requests')
@ApiOperation({ summary: 'Get all outstanding join requests for a league' })
@ApiResponse({ status: 200, description: 'List of join requests', type: [LeagueJoinRequestViewModel] })
async getJoinRequests(@Param('leagueId') leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
// No specific query DTO needed for GET, leagueId from param
return this.leagueService.getLeagueJoinRequests(leagueId);
}
@Post(':leagueId/join-requests/approve')
@ApiOperation({ summary: 'Approve a league join request' })
@ApiBody({ type: ApproveJoinRequestInput }) // Explicitly define body type for Swagger
@ApiResponse({ status: 200, description: 'Join request approved', type: ApproveJoinRequestOutput })
@ApiResponse({ status: 404, description: 'Join request not found' })
async approveJoinRequest(
@Param('leagueId') leagueId: string,
@Body() input: ApproveJoinRequestInput,
): Promise<ApproveJoinRequestOutput> {
return this.leagueService.approveLeagueJoinRequest({ ...input, leagueId });
}
@Post(':leagueId/join-requests/reject')
@ApiOperation({ summary: 'Reject a league join request' })
@ApiBody({ type: RejectJoinRequestInput })
@ApiResponse({ status: 200, description: 'Join request rejected', type: RejectJoinRequestOutput })
@ApiResponse({ status: 404, description: 'Join request not found' })
async rejectJoinRequest(
@Param('leagueId') leagueId: string,
@Body() input: RejectJoinRequestInput,
): Promise<RejectJoinRequestOutput> {
return this.leagueService.rejectLeagueJoinRequest({ ...input, leagueId });
}
@Get(':leagueId/permissions/:performerDriverId')
@ApiOperation({ summary: 'Get league admin permissions for a performer' })
@ApiResponse({ status: 200, description: 'League admin permissions', type: LeagueAdminPermissionsViewModel })
async getLeagueAdminPermissions(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
): Promise<LeagueAdminPermissionsViewModel> {
// No specific input DTO needed for Get, parameters from path
return this.leagueService.getLeagueAdminPermissions({ leagueId, performerDriverId });
}
@Patch(':leagueId/members/:targetDriverId/remove')
@ApiOperation({ summary: 'Remove a member from the league' })
@ApiBody({ type: RemoveLeagueMemberInput }) // Explicitly define body type for Swagger
@ApiResponse({ status: 200, description: 'Member removed successfully', type: RemoveLeagueMemberOutput })
@ApiResponse({ status: 400, description: 'Cannot remove member' })
@ApiResponse({ status: 404, description: 'Member not found' })
async removeLeagueMember(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('targetDriverId') targetDriverId: string,
@Body() input: RemoveLeagueMemberInput, // Body content for a patch often includes IDs
): Promise<RemoveLeagueMemberOutput> {
return this.leagueService.removeLeagueMember({ leagueId, performerDriverId, targetDriverId });
}
@Patch(':leagueId/members/:targetDriverId/role')
@ApiOperation({ summary: "Update a member's role in the league" })
@ApiBody({ type: UpdateLeagueMemberRoleInput }) // Explicitly define body type for Swagger
@ApiResponse({ status: 200, description: 'Member role updated successfully', type: UpdateLeagueMemberRoleOutput })
@ApiResponse({ status: 400, description: 'Cannot update role' })
@ApiResponse({ status: 404, description: 'Member not found' })
async updateLeagueMemberRole(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('targetDriverId') targetDriverId: string,
@Body() input: UpdateLeagueMemberRoleInput, // Body includes newRole, other for swagger
): Promise<UpdateLeagueMemberRoleOutput> {
return this.leagueService.updateLeagueMemberRole({ leagueId, performerDriverId, targetDriverId, newRole: input.newRole });
}
@Get(':leagueId/owner-summary/:ownerId')
@ApiOperation({ summary: 'Get owner summary for a league' })
@ApiResponse({ status: 200, description: 'League owner summary', type: LeagueOwnerSummaryViewModel })
@ApiResponse({ status: 404, description: 'Owner or league not found' })
async getLeagueOwnerSummary(
@Param('leagueId') leagueId: string,
@Param('ownerId') ownerId: string,
): Promise<LeagueOwnerSummaryViewModel | null> {
const query: GetLeagueOwnerSummaryQuery = { ownerId, leagueId };
return this.leagueService.getLeagueOwnerSummary(query);
}
@Get(':leagueId/config')
@ApiOperation({ summary: 'Get league full configuration' })
@ApiResponse({ status: 200, description: 'League configuration form model', type: LeagueConfigFormModelDto })
async getLeagueFullConfig(
@Param('leagueId') leagueId: string,
): Promise<LeagueConfigFormModelDto | null> {
const query: GetLeagueAdminConfigQuery = { leagueId };
return this.leagueService.getLeagueFullConfig(query);
}
@Get(':leagueId/protests')
@ApiOperation({ summary: 'Get protests for a league' })
@ApiResponse({ status: 200, description: 'List of protests for the league', type: LeagueAdminProtestsViewModel })
async getLeagueProtests(@Param('leagueId') leagueId: string): Promise<LeagueAdminProtestsViewModel> {
const query: GetLeagueProtestsQuery = { leagueId };
return this.leagueService.getLeagueProtests(query);
}
@Get(':leagueId/seasons')
@ApiOperation({ summary: 'Get seasons for a league' })
@ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryViewModel] })
async getLeagueSeasons(@Param('leagueId') leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
const query: GetLeagueSeasonsQuery = { leagueId };
return this.leagueService.getLeagueSeasons(query);
}
@Get(':leagueId/memberships')
@ApiOperation({ summary: 'Get league memberships' })
@ApiResponse({ status: 200, description: 'List of league members', type: LeagueMembershipsViewModel })
async getLeagueMemberships(@Param('leagueId') leagueId: string): Promise<LeagueMembershipsViewModel> {
return this.leagueService.getLeagueMemberships(leagueId);
}
@Get(':leagueId/standings')
@ApiOperation({ summary: 'Get league standings' })
@ApiResponse({ status: 200, description: 'League standings', type: LeagueStandingsViewModel })
async getLeagueStandings(@Param('leagueId') leagueId: string): Promise<LeagueStandingsViewModel> {
return this.leagueService.getLeagueStandings(leagueId);
}
@Get(':leagueId/schedule')
@ApiOperation({ summary: 'Get league schedule' })
@ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleViewModel })
async getLeagueSchedule(@Param('leagueId') leagueId: string): Promise<LeagueScheduleViewModel> {
return this.leagueService.getLeagueSchedule(leagueId);
}
@Get(':leagueId/stats')
@ApiOperation({ summary: 'Get league stats' })
@ApiResponse({ status: 200, description: 'League stats', type: LeagueStatsViewModel })
async getLeagueStats(@Param('leagueId') leagueId: string): Promise<LeagueStatsViewModel> {
return this.leagueService.getLeagueStats(leagueId);
}
@Get(':leagueId/admin')
@ApiOperation({ summary: 'Get league admin data' })
@ApiResponse({ status: 200, description: 'League admin data', type: LeagueAdminViewModel })
async getLeagueAdmin(@Param('leagueId') leagueId: string): Promise<LeagueAdminViewModel> {
return this.leagueService.getLeagueAdmin(leagueId);
}
@Post()
@ApiOperation({ summary: 'Create a new league' })
@ApiBody({ type: CreateLeagueInput })
@ApiResponse({ status: 201, description: 'League created successfully', type: CreateLeagueOutput })
async createLeague(@Body() input: CreateLeagueInput): Promise<CreateLeagueOutput> {
return this.leagueService.createLeague(input);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LeagueService } from './LeagueService';
import { LeagueController } from './LeagueController';
import { LeagueProviders } from './LeagueProviders';
@Module({
controllers: [LeagueController],
providers: LeagueProviders,
exports: [LeagueService],
})
export class LeagueModule {}

View File

@@ -0,0 +1,129 @@
import { Provider } from '@nestjs/common';
import { LeagueService } from './LeagueService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
// Import concrete in-memory implementations
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryLeagueStandingsRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
import { InMemoryLeagueScoringConfigRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueScoringConfigRepository';
import { InMemoryGameRepository } from '@adapters/racing/persistence/inmemory/InMemoryGameRepository';
import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository';
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
// Define injection tokens
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = 'ILeagueStandingsRepository';
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository';
export const LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN = 'ILeagueScoringConfigRepository';
export const GAME_REPOSITORY_TOKEN = 'IGameRepository';
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
export const LeagueProviders: Provider[] = [
LeagueService, // Provide the service itself
{
provide: LEAGUE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger), // Factory for InMemoryLeagueRepository
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueMembershipRepository(logger), // Factory for InMemoryLeagueMembershipRepository
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_STANDINGS_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger), // Factory for InMemoryLeagueStandingsRepository
inject: [LOGGER_TOKEN],
},
{
provide: STANDING_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryStandingRepository(logger), // Factory for InMemoryStandingRepository
inject: [LOGGER_TOKEN],
},
{
provide: SEASON_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySeasonRepository(logger), // Factory for InMemorySeasonRepository
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueScoringConfigRepository(logger), // Factory for InMemoryLeagueScoringConfigRepository
inject: [LOGGER_TOKEN],
},
{
provide: GAME_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryGameRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: PROTEST_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
GetAllLeaguesWithCapacityUseCase,
GetLeagueStandingsUseCase,
GetLeagueStatsUseCase,
GetLeagueFullConfigUseCase,
CreateLeagueWithSeasonAndScoringUseCase,
GetRaceProtestsUseCase,
GetTotalLeaguesUseCase,
GetLeagueJoinRequestsUseCase,
ApproveLeagueJoinRequestUseCase,
RejectLeagueJoinRequestUseCase,
RemoveLeagueMemberUseCase,
UpdateLeagueMemberRoleUseCase,
GetLeagueOwnerSummaryUseCase,
GetLeagueProtestsUseCase,
GetLeagueSeasonsUseCase,
GetLeagueMembershipsUseCase,
GetLeagueScheduleUseCase,
GetLeagueStatsUseCase,
GetLeagueAdminPermissionsUseCase,
];

View File

@@ -0,0 +1,170 @@
import { LeagueService } from './LeagueService';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import type { Logger } from '@core/shared/application/Logger';
describe('LeagueService', () => {
let service: LeagueService;
let mockGetTotalLeaguesUseCase: jest.Mocked<GetTotalLeaguesUseCase>;
let mockGetLeagueJoinRequestsUseCase: jest.Mocked<GetLeagueJoinRequestsUseCase>;
let mockApproveLeagueJoinRequestUseCase: jest.Mocked<ApproveLeagueJoinRequestUseCase>;
let mockLogger: jest.Mocked<Logger>;
beforeEach(() => {
mockGetTotalLeaguesUseCase = {
execute: jest.fn(),
} as any;
mockGetLeagueJoinRequestsUseCase = {
execute: jest.fn(),
} as any;
mockApproveLeagueJoinRequestUseCase = {
execute: jest.fn(),
} as any;
mockLogger = {
debug: jest.fn(),
} as any;
service = new LeagueService(
{} as any, // mockGetAllLeaguesWithCapacityUseCase
{} as any, // mockGetLeagueStandingsUseCase
{} as any, // mockGetLeagueStatsUseCase
{} as any, // mockGetLeagueFullConfigUseCase
{} as any, // mockCreateLeagueWithSeasonAndScoringUseCase
{} as any, // mockGetRaceProtestsUseCase
mockGetTotalLeaguesUseCase,
mockGetLeagueJoinRequestsUseCase,
mockApproveLeagueJoinRequestUseCase,
{} as any, // mockRejectLeagueJoinRequestUseCase
{} as any, // mockRemoveLeagueMemberUseCase
{} as any, // mockUpdateLeagueMemberRoleUseCase
{} as any, // mockGetLeagueOwnerSummaryUseCase
{} as any, // mockGetLeagueProtestsUseCase
{} as any, // mockGetLeagueSeasonsUseCase
{} as any, // mockGetLeagueMembershipsUseCase
{} as any, // mockGetLeagueScheduleUseCase
{} as any, // mockGetLeagueAdminPermissionsUseCase
mockLogger,
);
});
it('should get total leagues', async () => {
mockGetTotalLeaguesUseCase.execute.mockImplementation(async (params, presenter) => {
presenter.present({ totalLeagues: 5 });
});
const result = await service.getTotalLeagues();
expect(result).toEqual({ totalLeagues: 5 });
expect(mockLogger.debug).toHaveBeenCalledWith('[LeagueService] Fetching total leagues count.');
});
it('should get league join requests', async () => {
mockGetLeagueJoinRequestsUseCase.execute.mockImplementation(async (params, presenter) => {
presenter.present({
joinRequests: [{ id: 'req-1', leagueId: 'league-1', driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }],
drivers: [{ id: 'driver-1', name: 'Driver 1' }],
});
});
const result = await service.getLeagueJoinRequests('league-1');
expect(result).toEqual([{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
requestedAt: expect.any(Date),
message: 'msg',
driver: { id: 'driver-1', name: 'Driver 1' },
}]);
});
it('should approve league join request', async () => {
mockApproveLeagueJoinRequestUseCase.execute.mockImplementation(async (params, presenter) => {
presenter.present({ success: true, message: 'Join request approved.' });
});
const result = await service.approveLeagueJoinRequest({ leagueId: 'league-1', requestId: 'req-1' });
expect(result).toEqual({ success: true, message: 'Join request approved.' });
});
it('should reject league join request', async () => {
const mockRejectUseCase = {
execute: jest.fn().mockImplementation(async (params, presenter) => {
presenter.present({ success: true, message: 'Join request rejected.' });
}),
} as any;
service = new LeagueService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
mockRejectUseCase,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
mockLogger,
);
const result = await service.rejectLeagueJoinRequest({ requestId: 'req-1', leagueId: 'league-1' });
expect(result).toEqual({ success: true, message: 'Join request rejected.' });
});
it('should remove league member', async () => {
const mockRemoveUseCase = {
execute: jest.fn().mockImplementation(async (params, presenter) => {
presenter.present({ success: true });
}),
} as any;
service = new LeagueService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
mockRemoveUseCase,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
mockLogger,
);
const result = await service.removeLeagueMember({ leagueId: 'league-1', performerDriverId: 'performer-1', targetDriverId: 'driver-1' });
expect(result).toEqual({ success: true });
});
});

View File

@@ -0,0 +1,237 @@
import { Injectable, Inject } from '@nestjs/common';
import { AllLeaguesWithCapacityViewModel, LeagueStatsDto, LeagueJoinRequestViewModel, ApproveJoinRequestInput, ApproveJoinRequestOutput, RejectJoinRequestInput, RejectJoinRequestOutput, LeagueAdminPermissionsViewModel, RemoveLeagueMemberInput, RemoveLeagueMemberOutput, UpdateLeagueMemberRoleInput, UpdateLeagueMemberRoleOutput, LeagueOwnerSummaryViewModel, LeagueConfigFormModelDto, LeagueAdminProtestsViewModel, LeagueSeasonSummaryViewModel, GetLeagueAdminPermissionsInput, GetLeagueProtestsQuery, GetLeagueSeasonsQuery, GetLeagueAdminConfigQuery, GetLeagueOwnerSummaryQuery, LeagueMembershipsViewModel, LeagueStandingsViewModel, LeagueScheduleViewModel, LeagueStatsViewModel, LeagueAdminViewModel, CreateLeagueInput, CreateLeagueOutput } from './dto/LeagueDto';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
// API Presenters
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter';
import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter';
import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter';
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
// Tokens
import { LOGGER_TOKEN } from './LeagueProviders';
@Injectable()
export class LeagueService {
constructor(
private readonly getAllLeaguesWithCapacityUseCase: GetAllLeaguesWithCapacityUseCase,
private readonly getLeagueStandingsUseCase: GetLeagueStandingsUseCase,
private readonly getLeagueStatsUseCase: GetLeagueStatsUseCase,
private readonly getLeagueFullConfigUseCase: GetLeagueFullConfigUseCase,
private readonly createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase,
private readonly getRaceProtestsUseCase: GetRaceProtestsUseCase,
private readonly getTotalLeaguesUseCase: GetTotalLeaguesUseCase,
private readonly getLeagueJoinRequestsUseCase: GetLeagueJoinRequestsUseCase,
private readonly approveLeagueJoinRequestUseCase: ApproveLeagueJoinRequestUseCase,
private readonly rejectLeagueJoinRequestUseCase: RejectLeagueJoinRequestUseCase,
private readonly removeLeagueMemberUseCase: RemoveLeagueMemberUseCase,
private readonly updateLeagueMemberRoleUseCase: UpdateLeagueMemberRoleUseCase,
private readonly getLeagueOwnerSummaryUseCase: GetLeagueOwnerSummaryUseCase,
private readonly getLeagueProtestsUseCase: GetLeagueProtestsUseCase,
private readonly getLeagueSeasonsUseCase: GetLeagueSeasonsUseCase,
private readonly getLeagueMembershipsUseCase: GetLeagueMembershipsUseCase,
private readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase,
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
this.logger.debug('[LeagueService] Fetching all leagues with capacity.');
const presenter = new AllLeaguesWithCapacityPresenter();
await this.getAllLeaguesWithCapacityUseCase.execute(undefined, presenter);
return presenter.getViewModel()!;
}
async getTotalLeagues(): Promise<LeagueStatsDto> {
this.logger.debug('[LeagueService] Fetching total leagues count.');
const presenter = new TotalLeaguesPresenter();
await this.getTotalLeaguesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
}
async getLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`);
const presenter = new LeagueJoinRequestsPresenter();
await this.getLeagueJoinRequestsUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!.joinRequests;
}
async approveLeagueJoinRequest(input: ApproveJoinRequestInput): Promise<ApproveJoinRequestOutput> {
this.logger.debug('Approving join request:', input);
const presenter = new ApproveLeagueJoinRequestPresenter();
await this.approveLeagueJoinRequestUseCase.execute({ leagueId: input.leagueId, requestId: input.requestId }, presenter);
return presenter.getViewModel()!;
}
async rejectLeagueJoinRequest(input: RejectJoinRequestInput): Promise<RejectJoinRequestOutput> {
this.logger.debug('Rejecting join request:', input);
const presenter = new RejectLeagueJoinRequestPresenter();
await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId }, presenter);
return presenter.getViewModel()!;
}
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInput): Promise<LeagueAdminPermissionsViewModel> {
this.logger.debug('Getting league admin permissions', { query });
const presenter = new GetLeagueAdminPermissionsPresenter();
await this.getLeagueAdminPermissionsUseCase.execute(
{ leagueId: query.leagueId, performerDriverId: query.performerDriverId },
presenter
);
return presenter.getViewModel()!;
}
async removeLeagueMember(input: RemoveLeagueMemberInput): Promise<RemoveLeagueMemberOutput> {
this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId });
const presenter = new RemoveLeagueMemberPresenter();
await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId }, presenter);
return presenter.getViewModel()!;
}
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInput): Promise<UpdateLeagueMemberRoleOutput> {
this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
const presenter = new UpdateLeagueMemberRolePresenter();
await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole }, presenter);
return presenter.getViewModel()!;
}
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQuery): Promise<LeagueOwnerSummaryViewModel | null> {
this.logger.debug('Getting league owner summary:', query);
const presenter = new GetLeagueOwnerSummaryPresenter();
await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId }, presenter);
return presenter.getViewModel()!.summary;
}
async getLeagueFullConfig(query: GetLeagueAdminConfigQuery): Promise<LeagueConfigFormModelDto | null> {
this.logger.debug('Getting league full config', { query });
const presenter = new LeagueConfigPresenter();
try {
await this.getLeagueFullConfigUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.viewModel;
} catch (error) {
this.logger.error('Error getting league full config', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
async getLeagueProtests(query: GetLeagueProtestsQuery): Promise<LeagueAdminProtestsViewModel> {
this.logger.debug('Getting league protests:', query);
const presenter = new GetLeagueProtestsPresenter();
await this.getLeagueProtestsUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.getViewModel()!;
}
async getLeagueSeasons(query: GetLeagueSeasonsQuery): Promise<LeagueSeasonSummaryViewModel[]> {
this.logger.debug('Getting league seasons:', query);
const presenter = new GetLeagueSeasonsPresenter();
await this.getLeagueSeasonsUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.getViewModel()!.seasons;
}
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsViewModel> {
this.logger.debug('Getting league memberships', { leagueId });
const presenter = new GetLeagueMembershipsPresenter();
await this.getLeagueMembershipsUseCase.execute({ leagueId }, presenter);
return presenter.apiViewModel!;
}
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
this.logger.debug('Getting league standings', { leagueId });
const presenter = new LeagueStandingsPresenter();
await this.getLeagueStandingsUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!;
}
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
this.logger.debug('Getting league schedule', { leagueId });
const presenter = new LeagueSchedulePresenter();
await this.getLeagueScheduleUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!;
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsViewModel> {
this.logger.debug('Getting league stats', { leagueId });
const presenter = new LeagueStatsPresenter();
await this.getLeagueStatsUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!;
}
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminViewModel> {
this.logger.debug('Getting league admin data', { leagueId });
// For now, we'll keep the orchestration in the service since it combines multiple use cases
// TODO: Create a composite use case that handles all the admin data fetching
const joinRequests = await this.getLeagueJoinRequests(leagueId);
const config = await this.getLeagueFullConfig({ leagueId });
const protests = await this.getLeagueProtests({ leagueId });
const seasons = await this.getLeagueSeasons({ leagueId });
// Get owner summary - we need the ownerId, so we use a simple approach for now
// In a full implementation, we'd have a use case that gets league basic info
const ownerSummary = config ? await this.getLeagueOwnerSummary({ ownerId: 'placeholder', leagueId }) : null;
return {
joinRequests,
ownerSummary,
config: { form: config },
protests,
seasons,
};
}
async createLeague(input: CreateLeagueInput): Promise<CreateLeagueOutput> {
this.logger.debug('Creating league', { input });
const command = {
name: input.name,
description: input.description,
ownerId: input.ownerId,
visibility: 'unranked' as const,
gameId: 'iracing', // Assume default
maxDrivers: 32, // Default value
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
};
const result = await this.createLeagueWithSeasonAndScoringUseCase.execute(command);
return {
leagueId: result.leagueId,
success: true,
};
}
}

View File

@@ -0,0 +1,666 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsBoolean, IsDate, IsOptional, IsEnum, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { DriverDto } from '../../driver/dto/DriverDto';
import { RaceDto } from '../../race/dto/RaceDto';
export class LeagueSettingsDto {
@ApiProperty({ nullable: true })
@IsOptional()
@IsNumber()
maxDrivers?: number;
// Add other league settings properties as needed
}
export class LeagueWithCapacityViewModel {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
// ... other properties of LeagueWithCapacityViewModel
@ApiProperty({ nullable: true })
@IsOptional()
@IsString()
description?: string;
@ApiProperty()
@IsString()
ownerId: string;
@ApiProperty({ type: () => LeagueSettingsDto })
@ValidateNested()
@Type(() => LeagueSettingsDto)
settings: LeagueSettingsDto;
@ApiProperty()
@IsString()
createdAt: string;
@ApiProperty()
@IsNumber()
usedSlots: number;
@ApiProperty({ type: () => Object, nullable: true }) // Using Object for generic social links
@IsOptional()
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}
export class AllLeaguesWithCapacityViewModel {
@ApiProperty({ type: [LeagueWithCapacityViewModel] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeagueWithCapacityViewModel)
leagues: LeagueWithCapacityViewModel[];
@ApiProperty()
@IsNumber()
totalCount: number;
}
export class LeagueStatsDto {
@ApiProperty()
@IsNumber()
totalLeagues: number;
}
export class ProtestDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsString()
protestingDriverId: string;
@ApiProperty()
@IsString()
accusedDriverId: string;
@ApiProperty()
@IsDate()
@Type(() => Date)
submittedAt: Date;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ enum: ['pending', 'accepted', 'rejected'] })
@IsEnum(['pending', 'accepted', 'rejected'])
status: 'pending' | 'accepted' | 'rejected';
}
export class SeasonDto {
@ApiProperty()
@IsString()
seasonId: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
startDate?: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
endDate?: Date;
@ApiProperty({ enum: ['planned', 'active', 'completed'] })
@IsEnum(['planned', 'active', 'completed'])
status: 'planned' | 'active' | 'completed';
@ApiProperty()
@IsBoolean()
isPrimary: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonGroupId?: string;
}
export class LeagueJoinRequestViewModel {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
driverId: string;
@ApiProperty()
@IsDate()
@Type(() => Date)
requestedAt: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
message?: string;
@ApiProperty({ type: () => DriverDto, required: false })
@IsOptional()
@ValidateNested()
@Type(() => DriverDto)
driver?: DriverDto;
}
export class GetLeagueJoinRequestsQuery {
@ApiProperty()
@IsString()
leagueId: string;
}
export class ApproveJoinRequestInput {
@ApiProperty()
@IsString()
requestId: string;
@ApiProperty()
@IsString()
leagueId: string;
}
export class ApproveJoinRequestOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsString()
message?: string;
}
export class RejectJoinRequestInput {
@ApiProperty()
@IsString()
requestId: string;
@ApiProperty()
@IsString()
leagueId: string;
}
export class RejectJoinRequestOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsString()
message?: string;
}
export class GetLeagueAdminPermissionsInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
performerDriverId: string;
}
export class LeagueAdminPermissionsViewModel {
@ApiProperty()
@IsBoolean()
canRemoveMember: boolean;
@ApiProperty()
@IsBoolean()
canUpdateRoles: boolean;
}
export class RemoveLeagueMemberInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
performerDriverId: string;
@ApiProperty()
@IsString()
targetDriverId: string;
}
export class RemoveLeagueMemberOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
}
export class UpdateLeagueMemberRoleInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
performerDriverId: string;
@ApiProperty()
@IsString()
targetDriverId: string;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@IsEnum(['owner', 'manager', 'member'])
newRole: 'owner' | 'manager' | 'member';
}
export class UpdateLeagueMemberRoleOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
}
export class GetLeagueOwnerSummaryQuery {
@ApiProperty()
@IsString()
ownerId: string;
@ApiProperty()
@IsString()
leagueId: string;
}
export class LeagueOwnerSummaryViewModel {
@ApiProperty({ type: () => DriverDto })
@ValidateNested()
@Type(() => DriverDto)
driver: DriverDto;
@ApiProperty({ nullable: true })
@IsOptional()
@IsNumber()
rating: number | null;
@ApiProperty({ nullable: true })
@IsOptional()
@IsNumber()
rank: number | null;
}
export class LeagueConfigFormModelBasicsDto {
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ enum: ['public', 'private'] })
@IsEnum(['public', 'private'])
visibility: 'public' | 'private';
}
export class LeagueConfigFormModelStructureDto {
@ApiProperty()
@IsString()
@IsEnum(['solo', 'team'])
mode: 'solo' | 'team';
}
export class LeagueConfigFormModelScoringDto {
@ApiProperty()
@IsString()
type: string;
@ApiProperty()
@IsNumber()
points: number;
}
export class LeagueConfigFormModelDropPolicyDto {
@ApiProperty({ enum: ['none', 'worst_n'] })
@IsEnum(['none', 'worst_n'])
strategy: 'none' | 'worst_n';
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
n?: number;
}
export class LeagueConfigFormModelStewardingDto {
@ApiProperty({ enum: ['single_steward', 'committee_vote'] })
@IsEnum(['single_steward', 'committee_vote'])
decisionMode: 'single_steward' | 'committee_vote';
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
requiredVotes?: number;
@ApiProperty()
@IsBoolean()
requireDefense: boolean;
@ApiProperty()
@IsNumber()
defenseTimeLimit: number;
@ApiProperty()
@IsNumber()
voteTimeLimit: number;
@ApiProperty()
@IsNumber()
protestDeadlineHours: number;
@ApiProperty()
@IsNumber()
stewardingClosesHours: number;
@ApiProperty()
@IsBoolean()
notifyAccusedOnProtest: boolean;
@ApiProperty()
@IsBoolean()
notifyOnVoteRequired: boolean;
}
export class LeagueConfigFormModelTimingsDto {
@ApiProperty()
@IsString()
raceDayOfWeek: string;
@ApiProperty()
@IsNumber()
raceTimeHour: number;
@ApiProperty()
@IsNumber()
raceTimeMinute: number;
}
export class LeagueConfigFormModelDto {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ type: LeagueConfigFormModelBasicsDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelBasicsDto)
basics: LeagueConfigFormModelBasicsDto;
@ApiProperty({ type: LeagueConfigFormModelStructureDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelStructureDto)
structure: LeagueConfigFormModelStructureDto;
@ApiProperty({ type: [Object] })
@IsArray()
championships: any[];
@ApiProperty({ type: LeagueConfigFormModelScoringDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelScoringDto)
scoring: LeagueConfigFormModelScoringDto;
@ApiProperty({ type: LeagueConfigFormModelDropPolicyDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelDropPolicyDto)
dropPolicy: LeagueConfigFormModelDropPolicyDto;
@ApiProperty({ type: LeagueConfigFormModelTimingsDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelTimingsDto)
timings: LeagueConfigFormModelTimingsDto;
@ApiProperty({ type: LeagueConfigFormModelStewardingDto })
@ValidateNested()
@Type(() => LeagueConfigFormModelStewardingDto)
stewarding: LeagueConfigFormModelStewardingDto;
}
export class GetLeagueAdminConfigQuery {
@ApiProperty()
@IsString()
leagueId: string;
}
export class GetLeagueAdminConfigOutput {
@ApiProperty({ type: () => LeagueConfigFormModelDto, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => LeagueConfigFormModelDto)
form: LeagueConfigFormModelDto | null;
}
export class LeagueAdminConfigViewModel {
@ApiProperty({ type: () => LeagueConfigFormModelDto, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => LeagueConfigFormModelDto)
form: LeagueConfigFormModelDto | null;
}
export class GetLeagueProtestsQuery {
@ApiProperty()
@IsString()
leagueId: string;
}
export class LeagueAdminProtestsViewModel {
@ApiProperty({ type: [ProtestDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProtestDto)
protests: ProtestDto[];
@ApiProperty({ type: () => RaceDto })
@ValidateNested()
@Type(() => RaceDto)
racesById: { [raceId: string]: RaceDto };
@ApiProperty({ type: () => DriverDto })
@ValidateNested()
@Type(() => DriverDto)
driversById: { [driverId: string]: DriverDto };
}
export class GetLeagueSeasonsQuery {
@ApiProperty()
@IsString()
leagueId: string;
}
export class LeagueSeasonSummaryViewModel {
@ApiProperty()
@IsString()
seasonId: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
status: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
startDate?: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
endDate?: Date;
@ApiProperty()
@IsBoolean()
isPrimary: boolean;
@ApiProperty()
@IsBoolean()
isParallelActive: boolean;
}
export class LeagueAdminViewModel {
@ApiProperty({ type: [LeagueJoinRequestViewModel] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeagueJoinRequestViewModel)
joinRequests: LeagueJoinRequestViewModel[];
@ApiProperty({ type: () => LeagueOwnerSummaryViewModel, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => LeagueOwnerSummaryViewModel)
ownerSummary: LeagueOwnerSummaryViewModel | null;
@ApiProperty({ type: () => LeagueAdminConfigViewModel })
@ValidateNested()
@Type(() => LeagueAdminConfigViewModel)
config: LeagueAdminConfigViewModel;
@ApiProperty({ type: () => LeagueAdminProtestsViewModel })
@ValidateNested()
@Type(() => LeagueAdminProtestsViewModel)
protests: LeagueAdminProtestsViewModel;
@ApiProperty({ type: [LeagueSeasonSummaryViewModel] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeagueSeasonSummaryViewModel)
seasons: LeagueSeasonSummaryViewModel[];
}
export class LeagueMemberDto {
@ApiProperty()
@IsString()
driverId: string;
@ApiProperty({ type: () => DriverDto })
@ValidateNested()
@Type(() => DriverDto)
driver: DriverDto;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@IsEnum(['owner', 'manager', 'member'])
role: 'owner' | 'manager' | 'member';
@ApiProperty()
@IsDate()
@Type(() => Date)
joinedAt: Date;
}
export class LeagueMembershipsViewModel {
@ApiProperty({ type: [LeagueMemberDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeagueMemberDto)
members: LeagueMemberDto[];
}
export class LeagueStandingDto {
@ApiProperty()
@IsString()
driverId: string;
@ApiProperty({ type: () => DriverDto })
@ValidateNested()
@Type(() => DriverDto)
driver: DriverDto;
@ApiProperty()
@IsNumber()
points: number;
@ApiProperty()
@IsNumber()
rank: number;
}
export class LeagueStandingsViewModel {
@ApiProperty({ type: [LeagueStandingDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeagueStandingDto)
standings: LeagueStandingDto[];
}
export class LeagueScheduleViewModel {
@ApiProperty({ type: [RaceDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => RaceDto)
races: RaceDto[];
}
export class LeagueStatsViewModel {
@ApiProperty()
@IsNumber()
totalMembers: number;
@ApiProperty()
@IsNumber()
totalRaces: number;
@ApiProperty()
@IsNumber()
averageRating: number;
}
export class CreateLeagueInput {
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ enum: ['public', 'private'] })
@IsEnum(['public', 'private'])
visibility: 'public' | 'private';
@ApiProperty()
@IsString()
ownerId: string;
}
export class CreateLeagueOutput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsBoolean()
success: boolean;
}

View File

@@ -0,0 +1,30 @@
import { IAllLeaguesWithCapacityPresenter, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel } from '@core/racing/application/presenters/IAllLeaguesWithCapacityPresenter';
export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityPresenter {
private result: AllLeaguesWithCapacityViewModel | null = null;
reset() {
this.result = null;
}
present(dto: AllLeaguesWithCapacityResultDTO) {
const leagues = dto.leagues.map(league => ({
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: { maxDrivers: league.settings.maxDrivers || 0 },
createdAt: league.createdAt.toISOString(),
usedSlots: dto.memberCounts.get(league.id) || 0,
socialLinks: league.socialLinks,
}));
this.result = {
leagues,
totalCount: leagues.length,
};
}
getViewModel(): AllLeaguesWithCapacityViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,18 @@
import { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '@core/racing/application/presenters/IApproveLeagueJoinRequestPresenter';
export class ApproveLeagueJoinRequestPresenter implements IApproveLeagueJoinRequestPresenter {
private result: ApproveLeagueJoinRequestViewModel | null = null;
reset() {
this.result = null;
}
present(dto: ApproveLeagueJoinRequestResultDTO) {
this.result = dto;
}
getViewModel(): ApproveLeagueJoinRequestViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,17 @@
import { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel } from '@core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter';
export class GetLeagueAdminPermissionsPresenter implements IGetLeagueAdminPermissionsPresenter {
private result: GetLeagueAdminPermissionsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueAdminPermissionsResultDTO) {
this.result = dto;
}
getViewModel(): GetLeagueAdminPermissionsViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,47 @@
import { IGetLeagueMembershipsPresenter, GetLeagueMembershipsResultDTO, GetLeagueMembershipsViewModel } from '@core/racing/application/presenters/IGetLeagueMembershipsPresenter';
import { LeagueMembershipsViewModel } from '../dto/LeagueDto';
export class GetLeagueMembershipsPresenter implements IGetLeagueMembershipsPresenter {
private result: GetLeagueMembershipsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueMembershipsResultDTO) {
const driverMap = new Map(dto.drivers.map(d => [d.id, d]));
const members = dto.memberships.map(membership => ({
driverId: membership.driverId,
driver: driverMap.get(membership.driverId)!,
role: this.mapRole(membership.role) as 'owner' | 'manager' | 'member',
joinedAt: membership.joinedAt,
}));
this.result = { memberships: { members } };
}
private mapRole(role: string): 'owner' | 'manager' | 'member' {
switch (role) {
case 'owner':
return 'owner';
case 'admin':
return 'manager'; // Map admin to manager for API
case 'steward':
return 'member'; // Map steward to member for API
case 'member':
return 'member';
default:
return 'member';
}
}
getViewModel(): GetLeagueMembershipsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
// API-specific method
get apiViewModel(): LeagueMembershipsViewModel | null {
if (!this.result?.memberships) return null;
return this.result.memberships as LeagueMembershipsViewModel;
}
}

View File

@@ -0,0 +1,18 @@
import { IGetLeagueOwnerSummaryPresenter, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel } from '@core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter';
export class GetLeagueOwnerSummaryPresenter implements IGetLeagueOwnerSummaryPresenter {
private result: GetLeagueOwnerSummaryViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueOwnerSummaryResultDTO) {
this.result = { summary: dto.summary };
}
getViewModel(): GetLeagueOwnerSummaryViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,30 @@
import { IGetLeagueProtestsPresenter, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel } from '@core/racing/application/presenters/IGetLeagueProtestsPresenter';
export class GetLeagueProtestsPresenter implements IGetLeagueProtestsPresenter {
private result: GetLeagueProtestsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueProtestsResultDTO) {
const racesById = {};
dto.races.forEach(race => {
racesById[race.id] = race;
});
const driversById = {};
dto.drivers.forEach(driver => {
driversById[driver.id] = driver;
});
this.result = {
protests: dto.protests,
racesById,
driversById,
};
}
getViewModel(): GetLeagueProtestsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,27 @@
import { IGetLeagueSeasonsPresenter, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel } from '@core/racing/application/presenters/IGetLeagueSeasonsPresenter';
export class GetLeagueSeasonsPresenter implements IGetLeagueSeasonsPresenter {
private result: GetLeagueSeasonsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueSeasonsResultDTO) {
const seasons = dto.seasons.map(season => ({
seasonId: season.id,
name: season.name,
status: season.status,
startDate: season.startDate,
endDate: season.endDate,
isPrimary: season.isPrimary,
isParallelActive: season.isParallelActive,
}));
this.result = { seasons };
}
getViewModel(): GetLeagueSeasonsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,30 @@
import { LeagueAdminViewModel } from '../dto/LeagueDto';
export class LeagueAdminPresenter {
private result: LeagueAdminViewModel | null = null;
reset() {
this.result = null;
}
present(data: {
joinRequests: any[];
ownerSummary: any;
config: any;
protests: any;
seasons: any[];
}) {
this.result = {
joinRequests: data.joinRequests,
ownerSummary: data.ownerSummary,
config: { form: data.config },
protests: data.protests,
seasons: data.seasons,
};
}
getViewModel(): LeagueAdminViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,109 @@
import { ILeagueFullConfigPresenter, LeagueFullConfigData, LeagueConfigFormViewModel } from '@core/racing/application/presenters/ILeagueFullConfigPresenter';
import { LeagueConfigFormModelDto } from '../dto/LeagueDto';
export class LeagueConfigPresenter implements ILeagueFullConfigPresenter {
private result: LeagueConfigFormViewModel | null = null;
reset() {
this.result = null;
}
present(dto: LeagueFullConfigData) {
// Map from LeagueFullConfigData to LeagueConfigFormViewModel
const league = dto.league;
const settings = league.settings;
const stewarding = settings.stewarding;
this.result = {
leagueId: league.id,
basics: {
name: league.name,
description: league.description,
visibility: 'public', // TODO: Map visibility from league
gameId: 'iracing', // TODO: Map from game
},
structure: {
mode: 'solo', // TODO: Map from league settings
maxDrivers: settings.maxDrivers || 32,
multiClassEnabled: false, // TODO: Map
},
championships: {
enableDriverChampionship: true, // TODO: Map
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
customScoringEnabled: false, // TODO: Map
},
dropPolicy: {
strategy: 'none', // TODO: Map
},
timings: {
practiceMinutes: 30, // TODO: Map
qualifyingMinutes: 15,
mainRaceMinutes: 60,
sessionCount: 1,
roundsPlanned: 10, // TODO: Map
},
stewarding: {
decisionMode: stewarding?.decisionMode || 'admin_only',
requireDefense: stewarding?.requireDefense || false,
defenseTimeLimit: stewarding?.defenseTimeLimit || 48,
voteTimeLimit: stewarding?.voteTimeLimit || 72,
protestDeadlineHours: stewarding?.protestDeadlineHours || 48,
stewardingClosesHours: stewarding?.stewardingClosesHours || 168,
notifyAccusedOnProtest: stewarding?.notifyAccusedOnProtest || true,
notifyOnVoteRequired: stewarding?.notifyOnVoteRequired || true,
requiredVotes: stewarding?.requiredVotes,
},
};
}
getViewModel(): LeagueConfigFormViewModel | null {
return this.result;
}
// API-specific method to get the DTO
get viewModel(): LeagueConfigFormModelDto | null {
if (!this.result) return null;
// Map from LeagueConfigFormViewModel to LeagueConfigFormModelDto
return {
leagueId: this.result.leagueId,
basics: {
name: this.result.basics.name,
description: this.result.basics.description,
visibility: this.result.basics.visibility as 'public' | 'private',
},
structure: {
mode: this.result.structure.mode as 'solo' | 'team',
},
championships: [], // TODO: Map championships
scoring: {
type: 'standard', // TODO: Map scoring type
points: 25, // TODO: Map points
},
dropPolicy: {
strategy: this.result.dropPolicy.strategy as 'none' | 'worst_n',
n: this.result.dropPolicy.n,
},
timings: {
raceDayOfWeek: 'sunday', // TODO: Map from timings
raceTimeHour: 20,
raceTimeMinute: 0,
},
stewarding: {
decisionMode: this.result.stewarding.decisionMode === 'steward_vote' ? 'committee_vote' : 'single_steward',
requireDefense: this.result.stewarding.requireDefense,
defenseTimeLimit: this.result.stewarding.defenseTimeLimit,
voteTimeLimit: this.result.stewarding.voteTimeLimit,
protestDeadlineHours: this.result.stewarding.protestDeadlineHours,
stewardingClosesHours: this.result.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: this.result.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: this.result.stewarding.notifyOnVoteRequired,
requiredVotes: this.result.stewarding.requiredVotes,
},
};
}
}

View File

@@ -0,0 +1,27 @@
import { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '@core/racing/application/presenters/IGetLeagueJoinRequestsPresenter';
export class LeagueJoinRequestsPresenter implements IGetLeagueJoinRequestsPresenter {
private result: GetLeagueJoinRequestsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueJoinRequestsResultDTO) {
const driverMap = new Map(dto.drivers.map(d => [d.id, d]));
const joinRequests = dto.joinRequests.map(request => ({
id: request.id,
leagueId: request.leagueId,
driverId: request.driverId,
requestedAt: request.requestedAt,
message: request.message,
driver: driverMap.get(request.driverId) || null,
}));
this.result = { joinRequests };
}
getViewModel(): GetLeagueJoinRequestsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,23 @@
import { IGetLeagueSchedulePresenter, GetLeagueScheduleResultDTO, LeagueScheduleViewModel } from '@core/racing/application/presenters/IGetLeagueSchedulePresenter';
export class LeagueSchedulePresenter implements IGetLeagueSchedulePresenter {
private result: LeagueScheduleViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetLeagueScheduleResultDTO) {
this.result = {
races: dto.races.map(race => ({
id: race.id,
name: race.name,
date: race.scheduledAt.toISOString(),
})),
};
}
getViewModel(): LeagueScheduleViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,27 @@
import { ILeagueStandingsPresenter, LeagueStandingsResultDTO, LeagueStandingsViewModel } from '@core/racing/application/presenters/ILeagueStandingsPresenter';
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
private result: LeagueStandingsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: LeagueStandingsResultDTO) {
const driverMap = new Map(dto.drivers.map(d => [d.id, { id: d.id, name: d.name }]));
const standings = dto.standings
.sort((a, b) => a.position - b.position)
.map(standing => ({
driverId: standing.driverId,
driver: driverMap.get(standing.driverId)!,
points: standing.points,
rank: standing.position,
}));
this.result = { standings };
}
getViewModel(): LeagueStandingsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,17 @@
import { ILeagueStatsPresenter, LeagueStatsResultDTO, LeagueStatsViewModel } from '@core/racing/application/presenters/ILeagueStatsPresenter';
export class LeagueStatsPresenter implements ILeagueStatsPresenter {
private result: LeagueStatsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: LeagueStatsResultDTO) {
this.result = dto;
}
getViewModel(): LeagueStatsViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,18 @@
import { IRejectLeagueJoinRequestPresenter, RejectLeagueJoinRequestResultDTO, RejectLeagueJoinRequestViewModel } from '@core/racing/application/presenters/IRejectLeagueJoinRequestPresenter';
export class RejectLeagueJoinRequestPresenter implements IRejectLeagueJoinRequestPresenter {
private result: RejectLeagueJoinRequestViewModel | null = null;
reset() {
this.result = null;
}
present(dto: RejectLeagueJoinRequestResultDTO) {
this.result = dto;
}
getViewModel(): RejectLeagueJoinRequestViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,18 @@
import { IRemoveLeagueMemberPresenter, RemoveLeagueMemberResultDTO, RemoveLeagueMemberViewModel } from '@core/racing/application/presenters/IRemoveLeagueMemberPresenter';
export class RemoveLeagueMemberPresenter implements IRemoveLeagueMemberPresenter {
private result: RemoveLeagueMemberViewModel | null = null;
reset() {
this.result = null;
}
present(dto: RemoveLeagueMemberResultDTO) {
this.result = dto;
}
getViewModel(): RemoveLeagueMemberViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,20 @@
import { IGetTotalLeaguesPresenter, GetTotalLeaguesResultDTO, GetTotalLeaguesViewModel } from '@core/racing/application/presenters/IGetTotalLeaguesPresenter';
import { LeagueStatsDto } from '../dto/LeagueDto';
export class TotalLeaguesPresenter implements IGetTotalLeaguesPresenter {
private result: LeagueStatsDto | null = null;
reset() {
this.result = null;
}
present(dto: GetTotalLeaguesResultDTO) {
this.result = {
totalLeagues: dto.totalLeagues,
};
}
getViewModel(): LeagueStatsDto | null {
return this.result;
}
}

View File

@@ -0,0 +1,18 @@
import { IUpdateLeagueMemberRolePresenter, UpdateLeagueMemberRoleResultDTO, UpdateLeagueMemberRoleViewModel } from '@core/racing/application/presenters/IUpdateLeagueMemberRolePresenter';
export class UpdateLeagueMemberRolePresenter implements IUpdateLeagueMemberRolePresenter {
private result: UpdateLeagueMemberRoleViewModel | null = null;
reset() {
this.result = null;
}
present(dto: UpdateLeagueMemberRoleResultDTO) {
this.result = dto;
}
getViewModel(): UpdateLeagueMemberRoleViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import { Controller, Post, Body, HttpStatus, Res } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Response } from 'express';
import { MediaService } from './MediaService';
import { RequestAvatarGenerationInput, RequestAvatarGenerationOutput } from './dto/MediaDto'; // Assuming these DTOs are defined
@ApiTags('media')
@Controller('media')
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@Post('avatar/generate')
@ApiOperation({ summary: 'Request avatar generation' })
@ApiResponse({ status: 201, description: 'Avatar generation request submitted', type: RequestAvatarGenerationOutput })
async requestAvatarGeneration(
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.requestAvatarGeneration(input);
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MediaService } from './MediaService';
import { MediaController } from './MediaController';
import { MediaProviders } from './MediaProviders';
@Module({
controllers: [MediaController],
providers: MediaProviders,
exports: [MediaService],
})
export class MediaModule {}

View File

@@ -0,0 +1,82 @@
import { Provider } from '@nestjs/common';
import { MediaService } from './MediaService';
// Import core interfaces
import { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository';
import { FaceValidationPort } from '@core/media/application/ports/FaceValidationPort';
import { AvatarGenerationPort } from '@core/media/application/ports/AvatarGenerationPort';
import type { Logger } from '@core/shared/application';
// Import use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
// Define injection tokens
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort';
export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort';
export const LOGGER_TOKEN = 'Logger';
// Use case tokens
export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase';
// Mock implementations
class MockAvatarGenerationRepository implements IAvatarGenerationRepository {
async save(_request: any): Promise<void> {}
async findById(_id: string): Promise<any | null> { return null; }
async findByUserId(_userId: string): Promise<any[]> { return []; }
async findLatestByUserId(_userId: string): Promise<any | null> { return null; }
async delete(_id: string): Promise<void> {}
}
class MockFaceValidationAdapter implements FaceValidationPort {
async validateFacePhoto(data: string): Promise<any> {
return { isValid: true, hasFace: true, faceCount: 1 };
}
}
class MockAvatarGenerationAdapter implements AvatarGenerationPort {
async generateAvatars(options: any): Promise<any> {
return {
success: true,
avatars: [
{ url: 'https://cdn.example.com/avatars/mock-avatar-1.png' },
{ url: 'https://cdn.example.com/avatars/mock-avatar-2.png' },
{ url: 'https://cdn.example.com/avatars/mock-avatar-3.png' },
],
};
}
}
class MockLogger implements Logger {
debug(message: string, meta?: any): void {}
info(message: string, meta?: any): void {}
warn(message: string, meta?: any): void {}
error(message: string, error?: Error): void {}
}
export const MediaProviders: Provider[] = [
MediaService, // Provide the service itself
{
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
useClass: MockAvatarGenerationRepository,
},
{
provide: FACE_VALIDATION_PORT_TOKEN,
useClass: MockFaceValidationAdapter,
},
{
provide: AVATAR_GENERATION_PORT_TOKEN,
useClass: MockAvatarGenerationAdapter,
},
{
provide: LOGGER_TOKEN,
useClass: MockLogger,
},
// Use cases
{
provide: REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, logger: Logger) =>
new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, logger),
inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,32 @@
import { Injectable, Inject } from '@nestjs/common';
import { RequestAvatarGenerationInput, RequestAvatarGenerationOutput } from './dto/MediaDto';
// Use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
// Presenters
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
// Tokens
import { REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, LOGGER_TOKEN } from './MediaProviders';
import type { Logger } from '@core/shared/application';
@Injectable()
export class MediaService {
constructor(
@Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationOutput> {
this.logger.debug('[MediaService] Requesting avatar generation.');
const presenter = new RequestAvatarGenerationPresenter();
await this.requestAvatarGenerationUseCase.execute({
userId: input.userId,
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as any,
}, presenter);
return presenter.viewModel;
}
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsBoolean } from 'class-validator';
export class RequestAvatarGenerationInput {
@ApiProperty()
@IsString()
@IsNotEmpty()
userId: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
facePhotoData: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
suitColor: string;
}
export class RequestAvatarGenerationOutput {
@ApiProperty({ type: Boolean })
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsString()
requestId?: string;
@ApiProperty({ type: [String], required: false })
avatarUrls?: string[];
@ApiProperty({ required: false })
@IsString()
errorMessage?: string;
}
// Assuming FacePhotoData and SuitColor are simple string types for DTO purposes
export type FacePhotoData = string;
export type SuitColor = string;

View File

@@ -0,0 +1,28 @@
import { RequestAvatarGenerationOutput } from '../dto/MediaDto';
import type { IRequestAvatarGenerationPresenter, RequestAvatarGenerationResultDTO } from '@core/media/application/presenters/IRequestAvatarGenerationPresenter';
export class RequestAvatarGenerationPresenter implements IRequestAvatarGenerationPresenter {
private result: RequestAvatarGenerationOutput | null = null;
reset() {
this.result = null;
}
present(dto: RequestAvatarGenerationResultDTO) {
this.result = {
success: dto.status === 'completed',
requestId: dto.requestId,
avatarUrls: dto.avatarUrls,
errorMessage: dto.errorMessage,
};
}
get viewModel(): RequestAvatarGenerationOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
getViewModel(): RequestAvatarGenerationOutput {
return this.viewModel;
}
}

View File

@@ -0,0 +1,96 @@
import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { PaymentsService } from './PaymentsService';
import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dto/PaymentsDto';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get()
@ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
return this.paymentsService.getPayments(query);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
async createPayment(@Body() input: CreatePaymentInput): Promise<CreatePaymentOutput> {
return this.paymentsService.createPayment(input);
}
@Patch('status')
@ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
return this.paymentsService.updatePaymentStatus(input);
}
@Get('membership-fees')
@ApiOperation({ summary: 'Get membership fees and member payments' })
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
return this.paymentsService.getMembershipFees(query);
}
@Post('membership-fees')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create or update membership fee configuration' })
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
async upsertMembershipFee(@Body() input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
return this.paymentsService.upsertMembershipFee(input);
}
@Patch('membership-fees/member-payment')
@ApiOperation({ summary: 'Record or update a member payment' })
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
return this.paymentsService.updateMemberPayment(input);
}
@Get('prizes')
@ApiOperation({ summary: 'Get prizes for a league or season' })
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
async getPrizes(@Query() query: GetPrizesQuery): Promise<GetPrizesOutput> {
return this.paymentsService.getPrizes(query);
}
@Post('prizes')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new prize' })
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
async createPrize(@Body() input: CreatePrizeInput): Promise<CreatePrizeOutput> {
return this.paymentsService.createPrize(input);
}
@Patch('prizes/award')
@ApiOperation({ summary: 'Award a prize to a driver' })
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
async awardPrize(@Body() input: AwardPrizeInput): Promise<AwardPrizeOutput> {
return this.paymentsService.awardPrize(input);
}
@Delete('prizes')
@ApiOperation({ summary: 'Delete a prize' })
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
async deletePrize(@Query() query: DeletePrizeInput): Promise<DeletePrizeOutput> {
return this.paymentsService.deletePrize(query);
}
@Get('wallets')
@ApiOperation({ summary: 'Get wallet information and transactions' })
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
async getWallet(@Query() query: GetWalletQuery): Promise<GetWalletOutput> {
return this.paymentsService.getWallet(query);
}
@Post('wallets/transactions')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })
async processWalletTransaction(@Body() input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
return this.paymentsService.processWalletTransaction(input);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PaymentsService } from './PaymentsService';
import { PaymentsController } from './PaymentsController';
import { PaymentsProviders } from './PaymentsProviders';
@Module({
controllers: [PaymentsController],
providers: PaymentsProviders,
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,161 @@
import { Provider } from '@nestjs/common';
import { PaymentsService } from './PaymentsService';
// Import core interfaces
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository';
import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository';
import type { IWalletRepository, ITransactionRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application/Logger';
// Import use cases
import { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
import { CreatePaymentUseCase } from '@core/payments/application/use-cases/CreatePaymentUseCase';
import { UpdatePaymentStatusUseCase } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import { GetMembershipFeesUseCase } from '@core/payments/application/use-cases/GetMembershipFeesUseCase';
import { UpsertMembershipFeeUseCase } from '@core/payments/application/use-cases/UpsertMembershipFeeUseCase';
import { UpdateMemberPaymentUseCase } from '@core/payments/application/use-cases/UpdateMemberPaymentUseCase';
import { GetPrizesUseCase } from '@core/payments/application/use-cases/GetPrizesUseCase';
import { CreatePrizeUseCase } from '@core/payments/application/use-cases/CreatePrizeUseCase';
import { AwardPrizeUseCase } from '@core/payments/application/use-cases/AwardPrizeUseCase';
import { DeletePrizeUseCase } from '@core/payments/application/use-cases/DeletePrizeUseCase';
import { GetWalletUseCase } from '@core/payments/application/use-cases/GetWalletUseCase';
import { ProcessWalletTransactionUseCase } from '@core/payments/application/use-cases/ProcessWalletTransactionUseCase';
// Import concrete in-memory implementations
import { InMemoryPaymentRepository } from '/payments/persistence/inmemory/InMemoryPaymentRepository';
import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from '/payments/persistence/inmemory/InMemoryMembershipFeeRepository';
import { InMemoryPrizeRepository } from '/payments/persistence/inmemory/InMemoryPrizeRepository';
import { InMemoryWalletRepository, InMemoryTransactionRepository } from '/payments/persistence/inmemory/InMemoryWalletRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Repository injection tokens
export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository';
export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository';
export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository';
export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository';
export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger';
// Use case injection tokens
export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase';
export const CREATE_PAYMENT_USE_CASE_TOKEN = 'CreatePaymentUseCase';
export const UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN = 'UpdatePaymentStatusUseCase';
export const GET_MEMBERSHIP_FEES_USE_CASE_TOKEN = 'GetMembershipFeesUseCase';
export const UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN = 'UpsertMembershipFeeUseCase';
export const UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN = 'UpdateMemberPaymentUseCase';
export const GET_PRIZES_USE_CASE_TOKEN = 'GetPrizesUseCase';
export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase';
export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase';
export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase';
export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';
export const PaymentsProviders: Provider[] = [
PaymentsService,
// Logger
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Repositories (repositories are injected into use cases, NOT into services)
{
provide: PAYMENT_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryPaymentRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: MEMBERSHIP_FEE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMembershipFeeRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: MEMBER_PAYMENT_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMemberPaymentRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: PRIZE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryPrizeRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: WALLET_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryWalletRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: TRANSACTION_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTransactionRepository(logger),
inject: [LOGGER_TOKEN],
},
// Use cases (use cases receive repositories, services receive use cases)
{
provide: GET_PAYMENTS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new GetPaymentsUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: CREATE_PAYMENT_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new CreatePaymentUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new UpdatePaymentStatusUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
},
{
provide: UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository) => new UpsertMembershipFeeUseCase(membershipFeeRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN],
},
{
provide: UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
},
{
provide: GET_PRIZES_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new GetPrizesUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: CREATE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new CreatePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: AWARD_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new AwardPrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: DELETE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new DeletePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: GET_WALLET_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new GetWalletUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
},
{
provide: PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new ProcessWalletTransactionUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
},
];

View File

@@ -0,0 +1,190 @@
import { Injectable, Inject } from '@nestjs/common';
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import type { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
import type { CreatePaymentUseCase } from '@core/payments/application/use-cases/CreatePaymentUseCase';
import type { UpdatePaymentStatusUseCase } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import type { GetMembershipFeesUseCase } from '@core/payments/application/use-cases/GetMembershipFeesUseCase';
import type { UpsertMembershipFeeUseCase } from '@core/payments/application/use-cases/UpsertMembershipFeeUseCase';
import type { UpdateMemberPaymentUseCase } from '@core/payments/application/use-cases/UpdateMemberPaymentUseCase';
import type { GetPrizesUseCase } from '@core/payments/application/use-cases/GetPrizesUseCase';
import type { CreatePrizeUseCase } from '@core/payments/application/use-cases/CreatePrizeUseCase';
import type { AwardPrizeUseCase } from '@core/payments/application/use-cases/AwardPrizeUseCase';
import type { DeletePrizeUseCase } from '@core/payments/application/use-cases/DeletePrizeUseCase';
import type { GetWalletUseCase } from '@core/payments/application/use-cases/GetWalletUseCase';
import type { ProcessWalletTransactionUseCase } from '@core/payments/application/use-cases/ProcessWalletTransactionUseCase';
// Presenters
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { AwardPrizePresenter } from './presenters/AwardPrizePresenter';
import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
// DTOs
import type {
CreatePaymentInput,
CreatePaymentOutput,
UpdatePaymentStatusInput,
UpdatePaymentStatusOutput,
GetPaymentsQuery,
GetPaymentsOutput,
GetMembershipFeesQuery,
GetMembershipFeesOutput,
UpsertMembershipFeeInput,
UpsertMembershipFeeOutput,
UpdateMemberPaymentInput,
UpdateMemberPaymentOutput,
GetPrizesQuery,
GetPrizesOutput,
CreatePrizeInput,
CreatePrizeOutput,
AwardPrizeInput,
AwardPrizeOutput,
DeletePrizeInput,
DeletePrizeOutput,
GetWalletQuery,
GetWalletOutput,
ProcessWalletTransactionInput,
ProcessWalletTransactionOutput,
} from './dto/PaymentsDto';
// Injection tokens
import {
GET_PAYMENTS_USE_CASE_TOKEN,
CREATE_PAYMENT_USE_CASE_TOKEN,
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
GET_PRIZES_USE_CASE_TOKEN,
CREATE_PRIZE_USE_CASE_TOKEN,
AWARD_PRIZE_USE_CASE_TOKEN,
DELETE_PRIZE_USE_CASE_TOKEN,
GET_WALLET_USE_CASE_TOKEN,
PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
LOGGER_TOKEN,
} from './PaymentsProviders';
@Injectable()
export class PaymentsService {
constructor(
@Inject(GET_PAYMENTS_USE_CASE_TOKEN) private readonly getPaymentsUseCase: GetPaymentsUseCase,
@Inject(CREATE_PAYMENT_USE_CASE_TOKEN) private readonly createPaymentUseCase: CreatePaymentUseCase,
@Inject(UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN) private readonly updatePaymentStatusUseCase: UpdatePaymentStatusUseCase,
@Inject(GET_MEMBERSHIP_FEES_USE_CASE_TOKEN) private readonly getMembershipFeesUseCase: GetMembershipFeesUseCase,
@Inject(UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN) private readonly upsertMembershipFeeUseCase: UpsertMembershipFeeUseCase,
@Inject(UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN) private readonly updateMemberPaymentUseCase: UpdateMemberPaymentUseCase,
@Inject(GET_PRIZES_USE_CASE_TOKEN) private readonly getPrizesUseCase: GetPrizesUseCase,
@Inject(CREATE_PRIZE_USE_CASE_TOKEN) private readonly createPrizeUseCase: CreatePrizeUseCase,
@Inject(AWARD_PRIZE_USE_CASE_TOKEN) private readonly awardPrizeUseCase: AwardPrizeUseCase,
@Inject(DELETE_PRIZE_USE_CASE_TOKEN) private readonly deletePrizeUseCase: DeletePrizeUseCase,
@Inject(GET_WALLET_USE_CASE_TOKEN) private readonly getWalletUseCase: GetWalletUseCase,
@Inject(PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN) private readonly processWalletTransactionUseCase: ProcessWalletTransactionUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
this.logger.debug('[PaymentsService] Getting payments', { query });
const presenter = new GetPaymentsPresenter();
await this.getPaymentsUseCase.execute(query, presenter);
return presenter.viewModel;
}
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
this.logger.debug('[PaymentsService] Creating payment', { input });
const presenter = new CreatePaymentPresenter();
await this.createPaymentUseCase.execute(input, presenter);
return presenter.viewModel;
}
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
this.logger.debug('[PaymentsService] Updating payment status', { input });
const presenter = new UpdatePaymentStatusPresenter();
await this.updatePaymentStatusUseCase.execute(input, presenter);
return presenter.viewModel;
}
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
this.logger.debug('[PaymentsService] Getting membership fees', { query });
const presenter = new GetMembershipFeesPresenter();
await this.getMembershipFeesUseCase.execute(query, presenter);
return presenter.viewModel;
}
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
this.logger.debug('[PaymentsService] Upserting membership fee', { input });
const presenter = new UpsertMembershipFeePresenter();
await this.upsertMembershipFeeUseCase.execute(input, presenter);
return presenter.viewModel;
}
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
this.logger.debug('[PaymentsService] Updating member payment', { input });
const presenter = new UpdateMemberPaymentPresenter();
await this.updateMemberPaymentUseCase.execute(input, presenter);
return presenter.viewModel;
}
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesOutput> {
this.logger.debug('[PaymentsService] Getting prizes', { query });
const presenter = new GetPrizesPresenter();
await this.getPrizesUseCase.execute({ leagueId: query.leagueId!, seasonId: query.seasonId }, presenter);
return presenter.viewModel;
}
async createPrize(input: CreatePrizeInput): Promise<CreatePrizeOutput> {
this.logger.debug('[PaymentsService] Creating prize', { input });
const presenter = new CreatePrizePresenter();
await this.createPrizeUseCase.execute(input, presenter);
return presenter.viewModel;
}
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizeOutput> {
this.logger.debug('[PaymentsService] Awarding prize', { input });
const presenter = new AwardPrizePresenter();
await this.awardPrizeUseCase.execute(input, presenter);
return presenter.viewModel;
}
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizeOutput> {
this.logger.debug('[PaymentsService] Deleting prize', { input });
const presenter = new DeletePrizePresenter();
await this.deletePrizeUseCase.execute(input, presenter);
return presenter.viewModel;
}
async getWallet(query: GetWalletQuery): Promise<GetWalletOutput> {
this.logger.debug('[PaymentsService] Getting wallet', { query });
const presenter = new GetWalletPresenter();
await this.getWalletUseCase.execute({ leagueId: query.leagueId! }, presenter);
return presenter.viewModel;
}
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
this.logger.debug('[PaymentsService] Processing wallet transaction', { input });
const presenter = new ProcessWalletTransactionPresenter();
await this.processWalletTransactionUseCase.execute(input, presenter);
return presenter.viewModel;
}
}

View File

@@ -0,0 +1,566 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsEnum, IsOptional, IsDate, IsBoolean } from 'class-validator';
export enum PaymentType {
SPONSORSHIP = 'sponsorship',
MEMBERSHIP_FEE = 'membership_fee',
}
export enum PayerType {
SPONSOR = 'sponsor',
DRIVER = 'driver',
}
export enum PaymentStatus {
PENDING = 'pending',
COMPLETED = 'completed',
FAILED = 'failed',
REFUNDED = 'refunded',
}
export class PaymentDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty({ enum: PaymentType })
@IsEnum(PaymentType)
type: PaymentType;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsNumber()
platformFee: number;
@ApiProperty()
@IsNumber()
netAmount: number;
@ApiProperty()
@IsString()
payerId: string;
@ApiProperty({ enum: PayerType })
@IsEnum(PayerType)
payerType: PayerType;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonId?: string;
@ApiProperty({ enum: PaymentStatus })
@IsEnum(PaymentStatus)
status: PaymentStatus;
@ApiProperty()
@IsDate()
createdAt: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
completedAt?: Date;
}
export class CreatePaymentInput {
@ApiProperty({ enum: PaymentType })
@IsEnum(PaymentType)
type: PaymentType;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsString()
payerId: string;
@ApiProperty({ enum: PayerType })
@IsEnum(PayerType)
payerType: PayerType;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonId?: string;
}
export class CreatePaymentOutput {
@ApiProperty({ type: PaymentDto })
payment: PaymentDto;
}
export class UpdatePaymentStatusInput {
@ApiProperty()
@IsString()
paymentId: string;
@ApiProperty({ enum: PaymentStatus })
@IsEnum(PaymentStatus)
status: PaymentStatus;
}
export class UpdatePaymentStatusOutput {
@ApiProperty({ type: PaymentDto })
payment: PaymentDto;
}
export enum MembershipFeeType {
SEASON = 'season',
MONTHLY = 'monthly',
PER_RACE = 'per_race',
}
export enum MemberPaymentStatus {
PENDING = 'pending',
PAID = 'paid',
OVERDUE = 'overdue',
}
export class MembershipFeeDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonId?: string;
@ApiProperty({ enum: MembershipFeeType })
@IsEnum(MembershipFeeType)
type: MembershipFeeType;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsBoolean()
enabled: boolean;
@ApiProperty()
@IsDate()
createdAt: Date;
@ApiProperty()
@IsDate()
updatedAt: Date;
}
export class MemberPaymentDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
feeId: string;
@ApiProperty()
@IsString()
driverId: string;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsNumber()
platformFee: number;
@ApiProperty()
@IsNumber()
netAmount: number;
@ApiProperty({ enum: MemberPaymentStatus })
@IsEnum(MemberPaymentStatus)
status: MemberPaymentStatus;
@ApiProperty()
@IsDate()
dueDate: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
paidAt?: Date;
}
export class GetMembershipFeesQuery {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
driverId?: string;
}
export class GetMembershipFeesOutput {
@ApiProperty({ type: MembershipFeeDto, nullable: true })
fee: MembershipFeeDto | null;
@ApiProperty({ type: [MemberPaymentDto] })
payments: MemberPaymentDto[];
}
export class UpsertMembershipFeeInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonId?: string;
@ApiProperty({ enum: MembershipFeeType })
@IsEnum(MembershipFeeType)
type: MembershipFeeType;
@ApiProperty()
@IsNumber()
amount: number;
}
export class UpsertMembershipFeeOutput {
@ApiProperty({ type: MembershipFeeDto })
fee: MembershipFeeDto;
}
export class UpdateMemberPaymentInput {
@ApiProperty()
@IsString()
feeId: string;
@ApiProperty()
@IsString()
driverId: string;
@ApiProperty({ required: false, enum: MemberPaymentStatus })
@IsOptional()
@IsEnum(MemberPaymentStatus)
status?: MemberPaymentStatus;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
paidAt?: Date | string;
}
export class UpdateMemberPaymentOutput {
@ApiProperty({ type: MemberPaymentDto })
payment: MemberPaymentDto;
}
export class GetPaymentsQuery {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
leagueId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
payerId?: string;
@ApiProperty({ required: false, enum: PaymentType })
@IsOptional()
@IsEnum(PaymentType)
type?: PaymentType;
}
export class GetPaymentsOutput {
@ApiProperty({ type: [PaymentDto] })
payments: PaymentDto[];
}
export enum PrizeType {
CASH = 'cash',
MERCHANDISE = 'merchandise',
OTHER = 'other',
}
export class PrizeDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
seasonId: string;
@ApiProperty()
@IsNumber()
position: number;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty({ enum: PrizeType })
@IsEnum(PrizeType)
type: PrizeType;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty()
@IsBoolean()
awarded: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
awardedTo?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
awardedAt?: Date;
@ApiProperty()
@IsDate()
createdAt: Date;
}
export class GetPrizesQuery {
@ApiProperty()
@IsString()
leagueId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
seasonId?: string;
}
export class GetPrizesOutput {
@ApiProperty({ type: [PrizeDto] })
prizes: PrizeDto[];
}
export class CreatePrizeInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
seasonId: string;
@ApiProperty()
@IsNumber()
position: number;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty({ enum: PrizeType })
@IsEnum(PrizeType)
type: PrizeType;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
description?: string;
}
export class CreatePrizeOutput {
@ApiProperty({ type: PrizeDto })
prize: PrizeDto;
}
export class AwardPrizeInput {
@ApiProperty()
@IsString()
prizeId: string;
@ApiProperty()
@IsString()
driverId: string;
}
export class AwardPrizeOutput {
@ApiProperty({ type: PrizeDto })
prize: PrizeDto;
}
export class DeletePrizeInput {
@ApiProperty()
@IsString()
prizeId: string;
}
export class DeletePrizeOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
}
export enum TransactionType {
DEPOSIT = 'deposit',
WITHDRAWAL = 'withdrawal',
PLATFORM_FEE = 'platform_fee',
}
export enum ReferenceType {
SPONSORSHIP = 'sponsorship',
MEMBERSHIP_FEE = 'membership_fee',
PRIZE = 'prize',
}
export class WalletDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsNumber()
balance: number;
@ApiProperty()
@IsNumber()
totalRevenue: number;
@ApiProperty()
@IsNumber()
totalPlatformFees: number;
@ApiProperty()
@IsNumber()
totalWithdrawn: number;
@ApiProperty()
@IsDate()
createdAt: Date;
@ApiProperty()
@IsString()
currency: string;
}
export class TransactionDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
walletId: string;
@ApiProperty({ enum: TransactionType })
@IsEnum(TransactionType)
type: TransactionType;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
referenceId?: string;
@ApiProperty({ required: false, enum: ReferenceType })
@IsOptional()
@IsEnum(ReferenceType)
referenceType?: ReferenceType;
@ApiProperty()
@IsDate()
createdAt: Date;
}
export class GetWalletQuery {
@ApiProperty()
@IsString()
leagueId?: string;
}
export class GetWalletOutput {
@ApiProperty({ type: WalletDto })
wallet: WalletDto;
@ApiProperty({ type: [TransactionDto] })
transactions: TransactionDto[];
}
export class ProcessWalletTransactionInput {
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty({ enum: TransactionType })
@IsEnum(TransactionType)
type: TransactionType;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsString()
@IsNotEmpty()
description: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
referenceId?: string;
@ApiProperty({ required: false, enum: ReferenceType })
@IsOptional()
@IsEnum(ReferenceType)
referenceType?: ReferenceType;
}
export class ProcessWalletTransactionOutput {
@ApiProperty({ type: WalletDto })
wallet: WalletDto;
@ApiProperty({ type: TransactionDto })
transaction: TransactionDto;
}

View File

@@ -0,0 +1,26 @@
import type {
IAwardPrizePresenter,
AwardPrizeResultDTO,
AwardPrizeViewModel,
} from '@core/payments/application/presenters/IAwardPrizePresenter';
export class AwardPrizePresenter implements IAwardPrizePresenter {
private result: AwardPrizeViewModel | null = null;
reset() {
this.result = null;
}
present(dto: AwardPrizeResultDTO) {
this.result = dto;
}
getViewModel(): AwardPrizeViewModel | null {
return this.result;
}
get viewModel(): AwardPrizeViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
ICreatePaymentPresenter,
CreatePaymentResultDTO,
CreatePaymentViewModel,
} from '@core/payments/application/presenters/ICreatePaymentPresenter';
export class CreatePaymentPresenter implements ICreatePaymentPresenter {
private result: CreatePaymentViewModel | null = null;
reset() {
this.result = null;
}
present(dto: CreatePaymentResultDTO) {
this.result = dto;
}
getViewModel(): CreatePaymentViewModel | null {
return this.result;
}
get viewModel(): CreatePaymentViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
ICreatePrizePresenter,
CreatePrizeResultDTO,
CreatePrizeViewModel,
} from '@core/payments/application/presenters/ICreatePrizePresenter';
export class CreatePrizePresenter implements ICreatePrizePresenter {
private result: CreatePrizeViewModel | null = null;
reset() {
this.result = null;
}
present(dto: CreatePrizeResultDTO) {
this.result = dto;
}
getViewModel(): CreatePrizeViewModel | null {
return this.result;
}
get viewModel(): CreatePrizeViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IDeletePrizePresenter,
DeletePrizeResultDTO,
DeletePrizeViewModel,
} from '@core/payments/application/presenters/IDeletePrizePresenter';
export class DeletePrizePresenter implements IDeletePrizePresenter {
private result: DeletePrizeViewModel | null = null;
reset() {
this.result = null;
}
present(dto: DeletePrizeResultDTO) {
this.result = dto;
}
getViewModel(): DeletePrizeViewModel | null {
return this.result;
}
get viewModel(): DeletePrizeViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IGetMembershipFeesPresenter,
GetMembershipFeesResultDTO,
GetMembershipFeesViewModel,
} from '@core/payments/application/presenters/IGetMembershipFeesPresenter';
export class GetMembershipFeesPresenter implements IGetMembershipFeesPresenter {
private result: GetMembershipFeesViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetMembershipFeesResultDTO) {
this.result = dto;
}
getViewModel(): GetMembershipFeesViewModel | null {
return this.result;
}
get viewModel(): GetMembershipFeesViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IGetPaymentsPresenter,
GetPaymentsResultDTO,
GetPaymentsViewModel,
} from '@core/payments/application/presenters/IGetPaymentsPresenter';
export class GetPaymentsPresenter implements IGetPaymentsPresenter {
private result: GetPaymentsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetPaymentsResultDTO) {
this.result = dto;
}
getViewModel(): GetPaymentsViewModel | null {
return this.result;
}
get viewModel(): GetPaymentsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IGetPrizesPresenter,
GetPrizesResultDTO,
GetPrizesViewModel,
} from '@core/payments/application/presenters/IGetPrizesPresenter';
export class GetPrizesPresenter implements IGetPrizesPresenter {
private result: GetPrizesViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetPrizesResultDTO) {
this.result = dto;
}
getViewModel(): GetPrizesViewModel | null {
return this.result;
}
get viewModel(): GetPrizesViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IGetWalletPresenter,
GetWalletResultDTO,
GetWalletViewModel,
} from '@core/payments/application/presenters/IGetWalletPresenter';
export class GetWalletPresenter implements IGetWalletPresenter {
private result: GetWalletViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetWalletResultDTO) {
this.result = dto;
}
getViewModel(): GetWalletViewModel | null {
return this.result;
}
get viewModel(): GetWalletViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IProcessWalletTransactionPresenter,
ProcessWalletTransactionResultDTO,
ProcessWalletTransactionViewModel,
} from '@core/payments/application/presenters/IProcessWalletTransactionPresenter';
export class ProcessWalletTransactionPresenter implements IProcessWalletTransactionPresenter {
private result: ProcessWalletTransactionViewModel | null = null;
reset() {
this.result = null;
}
present(dto: ProcessWalletTransactionResultDTO) {
this.result = dto;
}
getViewModel(): ProcessWalletTransactionViewModel | null {
return this.result;
}
get viewModel(): ProcessWalletTransactionViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IUpdateMemberPaymentPresenter,
UpdateMemberPaymentResultDTO,
UpdateMemberPaymentViewModel,
} from '@core/payments/application/presenters/IUpdateMemberPaymentPresenter';
export class UpdateMemberPaymentPresenter implements IUpdateMemberPaymentPresenter {
private result: UpdateMemberPaymentViewModel | null = null;
reset() {
this.result = null;
}
present(dto: UpdateMemberPaymentResultDTO) {
this.result = dto;
}
getViewModel(): UpdateMemberPaymentViewModel | null {
return this.result;
}
get viewModel(): UpdateMemberPaymentViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IUpdatePaymentStatusPresenter,
UpdatePaymentStatusResultDTO,
UpdatePaymentStatusViewModel,
} from '@core/payments/application/presenters/IUpdatePaymentStatusPresenter';
export class UpdatePaymentStatusPresenter implements IUpdatePaymentStatusPresenter {
private result: UpdatePaymentStatusViewModel | null = null;
reset() {
this.result = null;
}
present(dto: UpdatePaymentStatusResultDTO) {
this.result = dto;
}
getViewModel(): UpdatePaymentStatusViewModel | null {
return this.result;
}
get viewModel(): UpdatePaymentStatusViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IUpsertMembershipFeePresenter,
UpsertMembershipFeeResultDTO,
UpsertMembershipFeeViewModel,
} from '@core/payments/application/presenters/IUpsertMembershipFeePresenter';
export class UpsertMembershipFeePresenter implements IUpsertMembershipFeePresenter {
private result: UpsertMembershipFeeViewModel | null = null;
reset() {
this.result = null;
}
present(dto: UpsertMembershipFeeResultDTO) {
this.result = dto;
}
getViewModel(): UpsertMembershipFeeViewModel | null {
return this.result;
}
get viewModel(): UpsertMembershipFeeViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,12 @@
export * from './GetPaymentsPresenter';
export * from './CreatePaymentPresenter';
export * from './UpdatePaymentStatusPresenter';
export * from './GetMembershipFeesPresenter';
export * from './UpsertMembershipFeePresenter';
export * from './UpdateMemberPaymentPresenter';
export * from './GetPrizesPresenter';
export * from './CreatePrizePresenter';
export * from './AwardPrizePresenter';
export * from './DeletePrizePresenter';
export * from './GetWalletPresenter';
export * from './ProcessWalletTransactionPresenter';

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { RaceService } from './RaceService';
import { AllRacesPageViewModel, RaceStatsDto } from './dto/RaceDto';
@ApiTags('races')
@Controller('races')
export class RaceController {
constructor(private readonly raceService: RaceService) {}
@Get('all')
@ApiOperation({ summary: 'Get all races' })
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageViewModel })
async getAllRaces(): Promise<AllRacesPageViewModel> {
return this.raceService.getAllRaces();
}
@Get('total-races')
@ApiOperation({ summary: 'Get the total number of races' })
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDto })
async getTotalRaces(): Promise<RaceStatsDto> {
return this.raceService.getTotalRaces();
}
// Add other Race endpoints here based on other presenters
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { RaceService } from './RaceService';
import { RaceController } from './RaceController';
import { RaceProviders } from './RaceProviders';
@Module({
controllers: [RaceController],
providers: RaceProviders,
exports: [RaceService],
})
export class RaceModule {}

View File

@@ -0,0 +1,52 @@
import { Provider } from '@nestjs/common';
import { RaceService } from './RaceService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
// Import concrete in-memory implementations
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { GetAllRacesUseCase } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import { GetTotalRacesUseCase } from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import { ImportRaceResultsApiUseCase } from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
// Define injection tokens
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const LOGGER_TOKEN = 'Logger';
export const RaceProviders: Provider[] = [
RaceService,
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: GetAllRacesUseCase,
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetAllRacesUseCase(raceRepo, leagueRepo),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
},
{
provide: GetTotalRacesUseCase,
useFactory: (raceRepo: IRaceRepository) => new GetTotalRacesUseCase(raceRepo),
inject: [RACE_REPOSITORY_TOKEN],
},
ImportRaceResultsApiUseCase,
];

View File

@@ -0,0 +1,50 @@
import { Injectable, Inject } from '@nestjs/common';
import { AllRacesPageViewModel, RaceStatsDto, ImportRaceResultsInput, ImportRaceResultsSummaryViewModel } from './dto/RaceDto';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { GetAllRacesUseCase } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import { GetTotalRacesUseCase } from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import { ImportRaceResultsApiUseCase } from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
// Presenters
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter';
// Tokens
import { LOGGER_TOKEN } from './RaceProviders';
@Injectable()
export class RaceService {
constructor(
private readonly getAllRacesUseCase: GetAllRacesUseCase,
private readonly getTotalRacesUseCase: GetTotalRacesUseCase,
private readonly importRaceResultsApiUseCase: ImportRaceResultsApiUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getAllRaces(): Promise<AllRacesPageViewModel> {
this.logger.debug('[RaceService] Fetching all races.');
const presenter = new GetAllRacesPresenter();
await this.getAllRacesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
}
async getTotalRaces(): Promise<RaceStatsDto> {
this.logger.debug('[RaceService] Fetching total races count.');
const presenter = new GetTotalRacesPresenter();
await this.getTotalRacesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
}
async importRaceResults(input: ImportRaceResultsInput): Promise<ImportRaceResultsSummaryViewModel> {
this.logger.debug('Importing race results:', input);
const presenter = new ImportRaceResultsApiPresenter();
await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent }, presenter);
return presenter.getViewModel()!;
}
}

View File

@@ -0,0 +1,78 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsBoolean, IsNumber } from 'class-validator';
export class RaceViewModel {
@ApiProperty()
id: string; // Assuming a race has an ID
@ApiProperty()
name: string; // Assuming a race has a name
@ApiProperty()
date: string; // Assuming a race has a date
@ApiProperty({ nullable: true })
leagueName?: string; // Assuming a race might belong to a league
// Add more race-related properties as needed based on the DTO from the application layer
}
export class AllRacesPageViewModel {
@ApiProperty({ type: [RaceViewModel] })
races: RaceViewModel[];
@ApiProperty()
totalCount: number;
}
export class RaceStatsDto {
@ApiProperty()
totalRaces: number;
}
export class ImportRaceResultsInput {
@ApiProperty()
@IsString()
@IsNotEmpty()
raceId: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
resultsFileContent: string;
}
export class ImportRaceResultsSummaryViewModel {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsNumber()
driversProcessed: number;
@ApiProperty()
@IsNumber()
resultsRecorded: number;
@ApiProperty({ type: [String], required: false })
errors?: string[];
}
export class RaceDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
date: string;
}

View File

@@ -0,0 +1,17 @@
import { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
export class GetAllRacesPresenter implements IGetAllRacesPresenter {
private result: AllRacesPageViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetAllRacesResultDTO) {
this.result = dto;
}
getViewModel(): AllRacesPageViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,20 @@
import { IGetTotalRacesPresenter, GetTotalRacesResultDTO } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
import { RaceStatsDto } from '../dto/RaceDto';
export class GetTotalRacesPresenter implements IGetTotalRacesPresenter {
private result: RaceStatsDto | null = null;
reset() {
this.result = null;
}
present(dto: GetTotalRacesResultDTO) {
this.result = {
totalRaces: dto.totalRaces,
};
}
getViewModel(): RaceStatsDto | null {
return this.result;
}
}

View File

@@ -0,0 +1,17 @@
import { IImportRaceResultsApiPresenter, ImportRaceResultsApiResultDTO, ImportRaceResultsSummaryViewModel } from '@core/racing/application/presenters/IImportRaceResultsApiPresenter';
export class ImportRaceResultsApiPresenter implements IImportRaceResultsApiPresenter {
private result: ImportRaceResultsSummaryViewModel | null = null;
reset() {
this.result = null;
}
present(dto: ImportRaceResultsApiResultDTO) {
this.result = dto;
}
getViewModel(): ImportRaceResultsSummaryViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,48 @@
import { Controller, Get, Post, Body, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDto, GetSponsorsOutput, CreateSponsorInput, CreateSponsorOutput, GetSponsorDashboardQueryParams, SponsorDashboardDTO, GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO } from './dto/SponsorDto';
@ApiTags('sponsors')
@Controller('sponsors')
export class SponsorController {
constructor(private readonly sponsorService: SponsorService) {}
@Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
@ApiResponse({ status: 200, description: 'Sponsorship pricing', type: GetEntitySponsorshipPricingResultDto })
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
return this.sponsorService.getEntitySponsorshipPricing();
}
@Get()
@ApiOperation({ summary: 'Get all sponsors' })
@ApiResponse({ status: 200, description: 'List of sponsors', type: GetSponsorsOutput })
async getSponsors(): Promise<GetSponsorsOutput> {
return this.sponsorService.getSponsors();
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new sponsor' })
@ApiResponse({ status: 201, description: 'Sponsor created', type: CreateSponsorOutput })
async createSponsor(@Body() input: CreateSponsorInput): Promise<CreateSponsorOutput> {
return this.sponsorService.createSponsor(input);
}
// Add other Sponsor endpoints here based on other presenters
@Get('dashboard/:sponsorId')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
@ApiResponse({ status: 200, description: 'Sponsor dashboard data', type: SponsorDashboardDTO })
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorDashboard(@Param('sponsorId') sponsorId: string): Promise<SponsorDashboardDTO | null> {
return this.sponsorService.getSponsorDashboard({ sponsorId });
}
@Get(':sponsorId/sponsorships')
@ApiOperation({ summary: 'Get all sponsorships for a given sponsor' })
@ApiResponse({ status: 200, description: 'List of sponsorships', type: SponsorSponsorshipsDTO })
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsorSponsorships(@Param('sponsorId') sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
return this.sponsorService.getSponsorSponsorships({ sponsorId });
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SponsorService } from './SponsorService';
import { SponsorController } from './SponsorController';
import { SponsorProviders } from './SponsorProviders';
@Module({
controllers: [SponsorController],
providers: SponsorProviders,
exports: [SponsorService],
})
export class SponsorModule {}

View File

@@ -0,0 +1,134 @@
import { Provider } from '@nestjs/common';
import { SponsorService } from './SponsorService';
// Import core interfaces
import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository';
import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { Logger } from '@core/shared/application';
// Import use cases
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
// Import concrete in-memory implementations
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository';
import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Define injection tokens
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
export const SEASON_SPONSORSHIP_REPOSITORY_TOKEN = 'ISeasonSponsorshipRepository';
export const SEASON_REPOSITORY_TOKEN = 'ISeasonRepository';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const SPONSORSHIP_PRICING_REPOSITORY_TOKEN = 'ISponsorshipPricingRepository';
export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestRepository';
export const LOGGER_TOKEN = 'Logger';
// Use case tokens
export const GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetSponsorshipPricingUseCase';
export const GET_SPONSORS_USE_CASE_TOKEN = 'GetSponsorsUseCase';
export const CREATE_SPONSOR_USE_CASE_TOKEN = 'CreateSponsorUseCase';
export const GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN = 'GetSponsorDashboardUseCase';
export const GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN = 'GetSponsorSponsorshipsUseCase';
export const GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetEntitySponsorshipPricingUseCase';
export const SponsorProviders: Provider[] = [
SponsorService,
// Repositories
{
provide: SPONSOR_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySponsorRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySeasonSponsorshipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: SEASON_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySeasonRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueMembershipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySponsorshipPricingRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySponsorshipRequestRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
useFactory: () => new GetSponsorshipPricingUseCase(),
inject: [],
},
{
provide: GET_SPONSORS_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository) => new GetSponsorsUseCase(sponsorRepo),
inject: [SPONSOR_REPOSITORY_TOKEN],
},
{
provide: CREATE_SPONSOR_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository) => new CreateSponsorUseCase(sponsorRepo),
inject: [SPONSOR_REPOSITORY_TOKEN],
},
{
provide: GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, leagueRepo: ILeagueRepository, leagueMembershipRepo: ILeagueMembershipRepository, raceRepo: IRaceRepository) =>
new GetSponsorDashboardUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo),
inject: [SPONSOR_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN],
},
{
provide: GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, raceRepo: IRaceRepository) =>
new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, raceRepo),
inject: [SPONSOR_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN],
},
{
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
useFactory: (sponsorshipPricingRepo: ISponsorshipPricingRepository, sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, logger: Logger) =>
new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, sponsorshipRequestRepo, seasonSponsorshipRepo, logger),
inject: [SPONSORSHIP_PRICING_REPOSITORY_TOKEN, SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,72 @@
import { Injectable, Inject } from '@nestjs/common';
import { GetEntitySponsorshipPricingResultDto, GetSponsorsOutput, CreateSponsorInput, CreateSponsorOutput, GetSponsorDashboardQueryParams, SponsorDashboardDTO, GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO, SponsorDto, SponsorDashboardMetricsDTO, SponsoredLeagueDTO, SponsorDashboardInvestmentDTO, SponsorshipDetailDTO } from './dto/SponsorDto';
// Use cases
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
// Presenters
import { GetSponsorshipPricingPresenter } from './presenters/GetSponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
// Tokens
import { GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, GET_SPONSORS_USE_CASE_TOKEN, CREATE_SPONSOR_USE_CASE_TOKEN, GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, LOGGER_TOKEN } from './SponsorProviders';
import type { Logger } from '@core/shared/application';
@Injectable()
export class SponsorService {
constructor(
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN) private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
@Inject(GET_SPONSORS_USE_CASE_TOKEN) private readonly getSponsorsUseCase: GetSponsorsUseCase,
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN) private readonly createSponsorUseCase: CreateSponsorUseCase,
@Inject(GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN) private readonly getSponsorDashboardUseCase: GetSponsorDashboardUseCase,
@Inject(GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN) private readonly getSponsorSponsorshipsUseCase: GetSponsorSponsorshipsUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
const presenter = new GetSponsorshipPricingPresenter();
await this.getSponsorshipPricingUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async getSponsors(): Promise<GetSponsorsOutput> {
this.logger.debug('[SponsorService] Fetching sponsors.');
const presenter = new GetSponsorsPresenter();
await this.getSponsorsUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async createSponsor(input: CreateSponsorInput): Promise<CreateSponsorOutput> {
this.logger.debug('[SponsorService] Creating sponsor.', { input });
const presenter = new CreateSponsorPresenter();
await this.createSponsorUseCase.execute(input, presenter);
return presenter.viewModel;
}
async getSponsorDashboard(params: GetSponsorDashboardQueryParams): Promise<SponsorDashboardDTO | null> {
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
const presenter = new GetSponsorDashboardPresenter();
await this.getSponsorDashboardUseCase.execute(params, presenter);
return presenter.viewModel as SponsorDashboardDTO | null;
}
async getSponsorSponsorships(params: GetSponsorSponsorshipsQueryParams): Promise<SponsorSponsorshipsDTO | null> {
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
const presenter = new GetSponsorSponsorshipsPresenter();
await this.getSponsorSponsorshipsUseCase.execute(params, presenter);
return presenter.viewModel as SponsorSponsorshipsDTO | null;
}
}

View File

@@ -0,0 +1,299 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsEnum, IsOptional, IsDate, IsBoolean, IsUrl, IsEmail } from 'class-validator';
export class SponsorshipPricingItemDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
level: string;
@ApiProperty()
@IsNumber()
price: number;
@ApiProperty()
@IsString()
currency: string;
}
export class GetEntitySponsorshipPricingResultDto {
@ApiProperty({ type: [SponsorshipPricingItemDto] })
pricing: SponsorshipPricingItemDto[];
}
export class SponsorDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty()
@IsString()
@IsEmail()
@IsNotEmpty()
contactEmail: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@IsUrl()
websiteUrl?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@IsUrl()
logoUrl?: string;
@ApiProperty()
@IsDate()
createdAt: Date;
}
export class GetSponsorsOutput {
@ApiProperty({ type: [SponsorDto] })
sponsors: SponsorDto[];
}
export class CreateSponsorInput {
@ApiProperty()
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty()
@IsEmail()
@IsNotEmpty()
contactEmail: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@IsUrl()
websiteUrl?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@IsUrl()
logoUrl?: string;
}
export class CreateSponsorOutput {
@ApiProperty({ type: SponsorDto })
sponsor: SponsorDto;
}
export class GetSponsorDashboardQueryParams {
@ApiProperty()
@IsString()
sponsorId: string;
}
export class SponsoredLeagueDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty({ enum: ['main', 'secondary'] })
@IsEnum(['main', 'secondary'])
tier: 'main' | 'secondary';
@ApiProperty()
@IsNumber()
drivers: number;
@ApiProperty()
@IsNumber()
races: number;
@ApiProperty()
@IsNumber()
impressions: number;
@ApiProperty({ enum: ['active', 'upcoming', 'completed'] })
@IsEnum(['active', 'upcoming', 'completed'])
status: 'active' | 'upcoming' | 'completed';
}
export class SponsorDashboardMetricsDTO {
@ApiProperty()
@IsNumber()
impressions: number;
@ApiProperty()
@IsNumber()
impressionsChange: number;
@ApiProperty()
@IsNumber()
uniqueViewers: number;
@ApiProperty()
@IsNumber()
viewersChange: number;
@ApiProperty()
@IsNumber()
races: number;
@ApiProperty()
@IsNumber()
drivers: number;
@ApiProperty()
@IsNumber()
exposure: number;
@ApiProperty()
@IsNumber()
exposureChange: number;
}
export class SponsorDashboardInvestmentDTO {
@ApiProperty()
@IsNumber()
activeSponsorships: number;
@ApiProperty()
@IsNumber()
totalInvestment: number;
@ApiProperty()
@IsNumber()
costPerThousandViews: number;
}
export class SponsorDashboardDTO {
@ApiProperty()
@IsString()
sponsorId: string;
@ApiProperty()
@IsString()
sponsorName: string;
@ApiProperty({ type: SponsorDashboardMetricsDTO })
metrics: SponsorDashboardMetricsDTO;
@ApiProperty({ type: [SponsoredLeagueDTO] })
sponsoredLeagues: SponsoredLeagueDTO[];
@ApiProperty({ type: SponsorDashboardInvestmentDTO })
investment: SponsorDashboardInvestmentDTO;
}
export class GetSponsorSponsorshipsQueryParams {
@ApiProperty()
@IsString()
sponsorId: string;
}
export class SponsorshipDetailDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
leagueId: string;
@ApiProperty()
@IsString()
leagueName: string;
@ApiProperty()
@IsString()
seasonId: string;
@ApiProperty()
@IsString()
seasonName: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
seasonStartDate?: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
seasonEndDate?: Date;
@ApiProperty({ enum: ['main', 'secondary'] })
@IsEnum(['main', 'secondary'])
tier: 'main' | 'secondary';
@ApiProperty({ enum: ['pending', 'active', 'expired', 'cancelled'] })
@IsEnum(['pending', 'active', 'expired', 'cancelled'])
status: 'pending' | 'active' | 'expired' | 'cancelled';
@ApiProperty()
pricing: {
amount: number;
currency: string;
};
@ApiProperty()
platformFee: {
amount: number;
currency: string;
};
@ApiProperty()
netAmount: {
amount: number;
currency: string;
};
@ApiProperty()
metrics: {
drivers: number;
races: number;
completedRaces: number;
impressions: number;
};
@ApiProperty()
createdAt: Date;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
activatedAt?: Date;
}
export class SponsorSponsorshipsDTO {
@ApiProperty()
@IsString()
sponsorId: string;
@ApiProperty()
@IsString()
sponsorName: string;
@ApiProperty({ type: [SponsorshipDetailDTO] })
sponsorships: SponsorshipDetailDTO[];
@ApiProperty()
summary: {
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
totalPlatformFees: number;
currency: string;
};
}
// Add other DTOs for sponsor-related logic as needed

View File

@@ -0,0 +1,22 @@
import { CreateSponsorViewModel, CreateSponsorResultDTO, ICreateSponsorPresenter } from '@core/racing/application/presenters/ICreateSponsorPresenter';
export class CreateSponsorPresenter implements ICreateSponsorPresenter {
private result: CreateSponsorViewModel | null = null;
reset() {
this.result = null;
}
present(dto: CreateSponsorResultDTO) {
this.result = dto;
}
getViewModel(): CreateSponsorViewModel | null {
return this.result;
}
get viewModel(): CreateSponsorViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import type { GetEntitySponsorshipPricingResultDTO } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import type { IEntitySponsorshipPricingPresenter } from '@core/racing/application/presenters/IEntitySponsorshipPricingPresenter';
export class GetEntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
private result: GetEntitySponsorshipPricingResultDTO | null = null;
reset() {
this.result = null;
}
present(dto: GetEntitySponsorshipPricingResultDTO | null) {
this.result = dto;
}
getViewModel(): GetEntitySponsorshipPricingResultDTO | null {
return this.result;
}
get viewModel(): GetEntitySponsorshipPricingResultDTO | null {
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import type { SponsorDashboardDTO } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import type { ISponsorDashboardPresenter, SponsorDashboardViewModel } from '@core/racing/application/presenters/ISponsorDashboardPresenter';
export class GetSponsorDashboardPresenter implements ISponsorDashboardPresenter {
private result: SponsorDashboardViewModel | null = null;
reset() {
this.result = null;
}
present(dto: SponsorDashboardDTO | null) {
this.result = dto;
}
getViewModel(): SponsorDashboardViewModel | null {
return this.result;
}
get viewModel(): SponsorDashboardViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import type { SponsorSponsorshipsDTO } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import type { ISponsorSponsorshipsPresenter, SponsorSponsorshipsViewModel } from '@core/racing/application/presenters/ISponsorSponsorshipsPresenter';
export class GetSponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
private result: SponsorSponsorshipsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: SponsorSponsorshipsDTO | null) {
this.result = dto;
}
getViewModel(): SponsorSponsorshipsViewModel | null {
return this.result;
}
get viewModel(): SponsorSponsorshipsViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import { GetSponsorsViewModel, GetSponsorsResultDTO, IGetSponsorsPresenter } from '@core/racing/application/presenters/IGetSponsorsPresenter';
export class GetSponsorsPresenter implements IGetSponsorsPresenter {
private result: GetSponsorsViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetSponsorsResultDTO) {
this.result = dto;
}
getViewModel(): GetSponsorsViewModel | null {
return this.result;
}
get viewModel(): GetSponsorsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,22 @@
import { GetSponsorshipPricingViewModel, GetSponsorshipPricingResultDTO, IGetSponsorshipPricingPresenter } from '@core/racing/application/presenters/IGetSponsorshipPricingPresenter';
export class GetSponsorshipPricingPresenter implements IGetSponsorshipPricingPresenter {
private result: GetSponsorshipPricingViewModel | null = null;
reset() {
this.result = null;
}
present(dto: GetSponsorshipPricingResultDTO) {
this.result = dto;
}
getViewModel(): GetSponsorshipPricingViewModel | null {
return this.result;
}
get viewModel(): GetSponsorshipPricingViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,93 @@
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
import { TeamService } from './TeamService';
import { AllTeamsViewModel, DriverTeamViewModel, TeamDetailsViewModel, TeamMembersViewModel, TeamJoinRequestsViewModel, CreateTeamInput, CreateTeamOutput, UpdateTeamInput, UpdateTeamOutput, ApproveTeamJoinRequestInput, ApproveTeamJoinRequestOutput, RejectTeamJoinRequestInput, RejectTeamJoinRequestOutput } from './dto/TeamDto';
@ApiTags('teams')
@Controller('teams')
export class TeamController {
constructor(private readonly teamService: TeamService) {}
@Get('all')
@ApiOperation({ summary: 'Get all teams' })
@ApiResponse({ status: 200, description: 'List of all teams', type: AllTeamsViewModel })
async getAllTeams(): Promise<AllTeamsViewModel> {
return this.teamService.getAllTeams();
}
@Get(':teamId')
@ApiOperation({ summary: 'Get team details' })
@ApiResponse({ status: 200, description: 'Team details', type: TeamDetailsViewModel })
@ApiResponse({ status: 404, description: 'Team not found' })
async getTeamDetails(
@Param('teamId') teamId: string,
): Promise<TeamDetailsViewModel | null> {
return this.teamService.getTeamDetails(teamId);
}
@Get(':teamId/members')
@ApiOperation({ summary: 'Get team members' })
@ApiResponse({ status: 200, description: 'Team members', type: TeamMembersViewModel })
async getTeamMembers(@Param('teamId') teamId: string): Promise<TeamMembersViewModel> {
return this.teamService.getTeamMembers(teamId);
}
@Get(':teamId/join-requests')
@ApiOperation({ summary: 'Get team join requests' })
@ApiResponse({ status: 200, description: 'Team join requests', type: TeamJoinRequestsViewModel })
async getTeamJoinRequests(@Param('teamId') teamId: string): Promise<TeamJoinRequestsViewModel> {
return this.teamService.getTeamJoinRequests(teamId);
}
@Post(':teamId/join-requests/approve')
@ApiOperation({ summary: 'Approve a team join request' })
@ApiBody({ type: ApproveTeamJoinRequestInput })
@ApiResponse({ status: 200, description: 'Join request approved', type: ApproveTeamJoinRequestOutput })
@ApiResponse({ status: 404, description: 'Join request not found' })
async approveJoinRequest(
@Param('teamId') teamId: string,
@Body() input: ApproveTeamJoinRequestInput,
): Promise<ApproveTeamJoinRequestOutput> {
return this.teamService.approveTeamJoinRequest({ ...input, teamId });
}
@Post(':teamId/join-requests/reject')
@ApiOperation({ summary: 'Reject a team join request' })
@ApiBody({ type: RejectTeamJoinRequestInput })
@ApiResponse({ status: 200, description: 'Join request rejected', type: RejectTeamJoinRequestOutput })
@ApiResponse({ status: 404, description: 'Join request not found' })
async rejectJoinRequest(
@Param('teamId') teamId: string,
@Body() input: RejectTeamJoinRequestInput,
): Promise<RejectTeamJoinRequestOutput> {
return this.teamService.rejectTeamJoinRequest({ ...input, teamId });
}
@Post()
@ApiOperation({ summary: 'Create a new team' })
@ApiBody({ type: CreateTeamInput })
@ApiResponse({ status: 201, description: 'Team created successfully', type: CreateTeamOutput })
async createTeam(@Body() input: CreateTeamInput): Promise<CreateTeamOutput> {
return this.teamService.createTeam(input);
}
@Patch(':teamId')
@ApiOperation({ summary: 'Update team details' })
@ApiBody({ type: UpdateTeamInput })
@ApiResponse({ status: 200, description: 'Team updated successfully', type: UpdateTeamOutput })
@ApiResponse({ status: 404, description: 'Team not found' })
async updateTeam(
@Param('teamId') teamId: string,
@Body() input: UpdateTeamInput,
): Promise<UpdateTeamOutput> {
return this.teamService.updateTeam({ ...input, teamId });
}
@Get('driver/:driverId')
@ApiOperation({ summary: 'Get team for a driver' })
@ApiResponse({ status: 200, description: 'Driver team membership', type: DriverTeamViewModel })
@ApiResponse({ status: 404, description: 'Driver not in a team' })
async getDriverTeam(@Param('driverId') driverId: string): Promise<DriverTeamViewModel | null> {
return this.teamService.getDriverTeam({ teamId: '', driverId });
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TeamService } from './TeamService';
import { TeamController } from './TeamController';
import { TeamProviders } from './TeamProviders';
@Module({
controllers: [TeamController],
providers: TeamProviders,
exports: [TeamService],
})
export class TeamModule {}

View File

@@ -0,0 +1,153 @@
import { Provider } from '@nestjs/common';
import { TeamService } from './TeamService';
// Import core interfaces
import { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { Logger } from '@core/shared/application/Logger';
// Import concrete in-memory implementations
import { InMemoryTeamRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamMembershipRepository';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { ApproveTeamJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveTeamJoinRequestUseCase';
import { RejectTeamJoinRequestUseCase } from '@core/racing/application/use-cases/RejectTeamJoinRequestUseCase';
// Import presenters for use case initialization
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
import { TeamMembersPresenter } from './presenters/TeamMembersPresenter';
import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter';
// Tokens
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const TEAM_GET_ALL_USE_CASE_TOKEN = 'GetAllTeamsUseCase';
export const TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN = 'GetDriverTeamUseCase';
export const TEAM_GET_DETAILS_USE_CASE_TOKEN = 'GetTeamDetailsUseCase';
export const TEAM_GET_MEMBERS_USE_CASE_TOKEN = 'GetTeamMembersUseCase';
export const TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN = 'GetTeamJoinRequestsUseCase';
export const TEAM_CREATE_USE_CASE_TOKEN = 'CreateTeamUseCase';
export const TEAM_UPDATE_USE_CASE_TOKEN = 'UpdateTeamUseCase';
export const TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN = 'ApproveTeamJoinRequestUseCase';
export const TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN = 'RejectTeamJoinRequestUseCase';
export const TEAM_LOGGER_TOKEN = 'Logger';
// Simple image service implementation for team module
class SimpleImageService implements IImageServicePort {
getDriverAvatar(driverId: string): string {
return `/api/media/avatars/${driverId}`;
}
getTeamLogo(teamId: string): string {
return `/api/media/teams/${teamId}/logo`;
}
getLeagueCover(leagueId: string): string {
return `/api/media/leagues/${leagueId}/cover`;
}
getLeagueLogo(leagueId: string): string {
return `/api/media/leagues/${leagueId}/logo`;
}
}
export const TeamProviders: Provider[] = [
TeamService, // Provide the service itself
{
provide: TEAM_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
inject: [TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamMembershipRepository(logger),
inject: [TEAM_LOGGER_TOKEN],
},
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [TEAM_LOGGER_TOKEN],
},
{
provide: IMAGE_SERVICE_TOKEN,
useClass: SimpleImageService,
},
{
provide: TEAM_LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: TEAM_GET_ALL_USE_CASE_TOKEN,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new GetAllTeamsUseCase(teamRepo, membershipRepo, logger),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new GetDriverTeamUseCase(teamRepo, membershipRepo, logger, new DriverTeamPresenter()),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_GET_DETAILS_USE_CASE_TOKEN,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new GetTeamDetailsUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: TEAM_GET_MEMBERS_USE_CASE_TOKEN,
useFactory: (
membershipRepo: ITeamMembershipRepository,
driverRepo: IDriverRepository,
imageService: IImageServicePort,
logger: Logger,
) => new GetTeamMembersUseCase(membershipRepo, driverRepo, imageService, logger, new TeamMembersPresenter()),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN,
useFactory: (
membershipRepo: ITeamMembershipRepository,
driverRepo: IDriverRepository,
imageService: IImageServicePort,
logger: Logger,
) => new GetTeamJoinRequestsUseCase(membershipRepo, driverRepo, imageService, logger, new TeamJoinRequestsPresenter()),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_CREATE_USE_CASE_TOKEN,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new CreateTeamUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: TEAM_UPDATE_USE_CASE_TOKEN,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new UpdateTeamUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN,
useFactory: (membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new ApproveTeamJoinRequestUseCase(membershipRepo, logger),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, TEAM_LOGGER_TOKEN],
},
{
provide: TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN,
useFactory: (membershipRepo: ITeamMembershipRepository) =>
new RejectTeamJoinRequestUseCase(membershipRepo),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
];

View File

@@ -0,0 +1,168 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TeamService } from './TeamService';
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import type { Logger } from '@core/shared/application/Logger';
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
import { AllTeamsViewModel, DriverTeamViewModel, GetDriverTeamQuery } from './dto/TeamDto';
import { TEAM_GET_ALL_USE_CASE_TOKEN, TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, TEAM_LOGGER_TOKEN } from './TeamProviders';
describe('TeamService', () => {
let service: TeamService;
let getAllTeamsUseCase: jest.Mocked<GetAllTeamsUseCase>;
let getDriverTeamUseCase: jest.Mocked<GetDriverTeamUseCase>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const mockGetAllTeamsUseCase = {
execute: jest.fn(),
};
const mockGetDriverTeamUseCase = {
execute: jest.fn(),
};
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TeamService,
{
provide: TEAM_GET_ALL_USE_CASE_TOKEN,
useValue: mockGetAllTeamsUseCase,
},
{
provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN,
useValue: mockGetDriverTeamUseCase,
},
{
provide: TEAM_LOGGER_TOKEN,
useValue: mockLogger,
},
],
}).compile();
service = module.get<TeamService>(TeamService);
getAllTeamsUseCase = module.get(TEAM_GET_ALL_USE_CASE_TOKEN);
getDriverTeamUseCase = module.get(TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN);
logger = module.get(TEAM_LOGGER_TOKEN);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAllTeams', () => {
it('should create presenter, call use case, and return view model', async () => {
const mockViewModel: AllTeamsViewModel = {
teams: [],
totalCount: 0,
};
const mockPresenter = {
reset: jest.fn(),
present: jest.fn(),
get viewModel(): AllTeamsViewModel {
return mockViewModel;
},
};
// Mock the presenter constructor
const originalConstructor = AllTeamsPresenter;
(AllTeamsPresenter as any) = jest.fn().mockImplementation(() => mockPresenter);
// Mock the use case to call the presenter
getAllTeamsUseCase.execute.mockImplementation(async (input, presenter) => {
presenter.present({ teams: [] });
});
const result = await service.getAllTeams();
expect(AllTeamsPresenter).toHaveBeenCalled();
expect(getAllTeamsUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter);
expect(result).toBe(mockViewModel);
// Restore
AllTeamsPresenter = originalConstructor;
});
});
describe('getDriverTeam', () => {
it('should create presenter, call use case, and return view model', async () => {
const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' };
const mockViewModel: DriverTeamViewModel = {
team: {
id: 'team1',
name: 'Team 1',
tag: 'T1',
description: 'Description',
ownerId: 'driver1',
leagues: [],
},
membership: {
role: 'owner' as any,
joinedAt: new Date(),
isActive: true,
},
isOwner: true,
canManage: true,
};
const mockPresenter = {
reset: jest.fn(),
present: jest.fn(),
get viewModel(): DriverTeamViewModel {
return mockViewModel;
},
};
// Mock the presenter constructor
const originalConstructor = DriverTeamPresenter;
(DriverTeamPresenter as any) = jest.fn().mockImplementation(() => mockPresenter);
// Mock the use case to call the presenter
getDriverTeamUseCase.execute.mockImplementation(async (input, presenter) => {
presenter.present({
team: {
id: 'team1',
name: 'Team 1',
tag: 'T1',
description: 'Description',
ownerId: 'driver1',
leagues: [],
},
membership: {
role: 'owner',
status: 'active',
joinedAt: new Date(),
},
driverId: 'driver1',
});
});
const result = await service.getDriverTeam(query);
expect(DriverTeamPresenter).toHaveBeenCalled();
expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' }, mockPresenter);
expect(result).toBe(mockViewModel);
// Restore
DriverTeamPresenter = originalConstructor;
});
it('should return null on error', async () => {
const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' };
// Mock the use case to throw an error
getDriverTeamUseCase.execute.mockRejectedValue(new Error('Team not found'));
const result = await service.getDriverTeam(query);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,168 @@
import { Injectable, Inject } from '@nestjs/common';
import { AllTeamsViewModel, GetDriverTeamQuery, DriverTeamViewModel, TeamDetailsViewModel, TeamMembersViewModel, TeamJoinRequestsViewModel, CreateTeamInput, CreateTeamOutput, UpdateTeamInput, UpdateTeamOutput, ApproveTeamJoinRequestInput, ApproveTeamJoinRequestOutput, RejectTeamJoinRequestInput, RejectTeamJoinRequestOutput } from './dto/TeamDto';
// Use cases
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { ApproveTeamJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveTeamJoinRequestUseCase';
import { RejectTeamJoinRequestUseCase } from '@core/racing/application/use-cases/RejectTeamJoinRequestUseCase';
// Presenters
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
import { TeamDetailsPresenter } from './presenters/TeamDetailsPresenter';
import { TeamMembersPresenter } from './presenters/TeamMembersPresenter';
import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter';
// Logger
import type { Logger } from '@core/shared/application/Logger';
// Tokens
import {
TEAM_GET_ALL_USE_CASE_TOKEN,
TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN,
TEAM_GET_DETAILS_USE_CASE_TOKEN,
TEAM_GET_MEMBERS_USE_CASE_TOKEN,
TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN,
TEAM_CREATE_USE_CASE_TOKEN,
TEAM_UPDATE_USE_CASE_TOKEN,
TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN,
TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN,
TEAM_LOGGER_TOKEN
} from './TeamProviders';
@Injectable()
export class TeamService {
constructor(
@Inject(TEAM_GET_ALL_USE_CASE_TOKEN) private readonly getAllTeamsUseCase: GetAllTeamsUseCase,
@Inject(TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN) private readonly getDriverTeamUseCase: GetDriverTeamUseCase,
@Inject(TEAM_GET_DETAILS_USE_CASE_TOKEN) private readonly getTeamDetailsUseCase: GetTeamDetailsUseCase,
@Inject(TEAM_GET_MEMBERS_USE_CASE_TOKEN) private readonly getTeamMembersUseCase: GetTeamMembersUseCase,
@Inject(TEAM_GET_JOIN_REQUESTS_USE_CASE_TOKEN) private readonly getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase,
@Inject(TEAM_CREATE_USE_CASE_TOKEN) private readonly createTeamUseCase: CreateTeamUseCase,
@Inject(TEAM_UPDATE_USE_CASE_TOKEN) private readonly updateTeamUseCase: UpdateTeamUseCase,
@Inject(TEAM_APPROVE_JOIN_REQUEST_USE_CASE_TOKEN) private readonly approveTeamJoinRequestUseCase: ApproveTeamJoinRequestUseCase,
@Inject(TEAM_REJECT_JOIN_REQUEST_USE_CASE_TOKEN) private readonly rejectTeamJoinRequestUseCase: RejectTeamJoinRequestUseCase,
@Inject(TEAM_LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getAllTeams(): Promise<AllTeamsViewModel> {
this.logger.debug('[TeamService] Fetching all teams.');
const presenter = new AllTeamsPresenter();
await this.getAllTeamsUseCase.execute(undefined, presenter);
return presenter.viewModel as unknown as AllTeamsViewModel;
}
async getDriverTeam(query: GetDriverTeamQuery): Promise<DriverTeamViewModel | null> {
this.logger.debug(`[TeamService] Fetching driver team for driverId: ${query.driverId}`);
const presenter = new DriverTeamPresenter();
try {
await this.getDriverTeamUseCase.execute({ driverId: query.driverId }, presenter);
return presenter.viewModel as unknown as DriverTeamViewModel;
} catch (error) {
this.logger.error(`Error fetching driver team: ${error}`);
return null;
}
}
async getTeamDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
this.logger.debug(`[TeamService] Fetching team details for teamId: ${teamId}`);
const presenter = new TeamDetailsPresenter();
try {
await this.getTeamDetailsUseCase.execute({ teamId, driverId: '' }, presenter);
return presenter.viewModel as unknown as TeamDetailsViewModel;
} catch (error) {
this.logger.error(`Error fetching team details: ${error}`);
return null;
}
}
async getTeamMembers(teamId: string): Promise<TeamMembersViewModel> {
this.logger.debug(`[TeamService] Fetching team members for teamId: ${teamId}`);
const presenter = new TeamMembersPresenter();
await this.getTeamMembersUseCase.execute({ teamId }, presenter);
return presenter.viewModel as unknown as TeamMembersViewModel;
}
async getTeamJoinRequests(teamId: string): Promise<TeamJoinRequestsViewModel> {
this.logger.debug(`[TeamService] Fetching join requests for teamId: ${teamId}`);
const presenter = new TeamJoinRequestsPresenter();
await this.getTeamJoinRequestsUseCase.execute({ teamId }, presenter);
return presenter.viewModel as unknown as TeamJoinRequestsViewModel;
}
async createTeam(input: CreateTeamInput): Promise<CreateTeamOutput> {
this.logger.debug('[TeamService] Creating team', input);
try {
const result = await this.createTeamUseCase.execute({
name: input.name,
tag: input.tag,
description: input.description,
ownerId: input.ownerId,
leagues: [],
});
return {
teamId: result.team.id,
success: true,
};
} catch (error) {
this.logger.error(`Error creating team: ${error}`);
throw error;
}
}
async updateTeam(input: UpdateTeamInput & { teamId: string }): Promise<UpdateTeamOutput> {
this.logger.debug('[TeamService] Updating team', input);
try {
await this.updateTeamUseCase.execute({
teamId: input.teamId,
updates: {
name: input.name,
tag: input.tag,
description: input.description,
},
updatedBy: input.updatedBy,
});
return { success: true };
} catch (error) {
this.logger.error(`Error updating team: ${error}`);
throw error;
}
}
async approveTeamJoinRequest(input: ApproveTeamJoinRequestInput & { teamId: string }): Promise<ApproveTeamJoinRequestOutput> {
this.logger.debug('[TeamService] Approving team join request', input);
try {
await this.approveTeamJoinRequestUseCase.execute({ requestId: input.requestId });
return { success: true };
} catch (error) {
this.logger.error(`Error approving join request: ${error}`);
throw error;
}
}
async rejectTeamJoinRequest(input: RejectTeamJoinRequestInput & { teamId: string }): Promise<RejectTeamJoinRequestOutput> {
this.logger.debug('[TeamService] Rejecting team join request', input);
try {
await this.rejectTeamJoinRequestUseCase.execute({ requestId: input.requestId });
return { success: true };
} catch (error) {
this.logger.error(`Error rejecting join request: ${error}`);
throw error;
}
}
}

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