integration tests
This commit is contained in:
64
core/dashboard/application/dto/DashboardDTO.ts
Normal file
64
core/dashboard/application/dto/DashboardDTO.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Dashboard DTO (Data Transfer Object)
|
||||
*
|
||||
* Represents the complete dashboard data structure returned to the client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Driver statistics section
|
||||
*/
|
||||
export interface DriverStatisticsDTO {
|
||||
rating: number;
|
||||
rank: number;
|
||||
starts: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
leagues: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming race section
|
||||
*/
|
||||
export interface UpcomingRaceDTO {
|
||||
trackName: string;
|
||||
carType: string;
|
||||
scheduledDate: string;
|
||||
timeUntilRace: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Championship standing section
|
||||
*/
|
||||
export interface ChampionshipStandingDTO {
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
totalDrivers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent activity section
|
||||
*/
|
||||
export interface RecentActivityDTO {
|
||||
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
status: 'success' | 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard DTO
|
||||
*
|
||||
* Complete dashboard data structure for a driver.
|
||||
*/
|
||||
export interface DashboardDTO {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
statistics: DriverStatisticsDTO;
|
||||
upcomingRaces: UpcomingRaceDTO[];
|
||||
championshipStandings: ChampionshipStandingDTO[];
|
||||
recentActivity: RecentActivityDTO[];
|
||||
}
|
||||
43
core/dashboard/application/ports/DashboardEventPublisher.ts
Normal file
43
core/dashboard/application/ports/DashboardEventPublisher.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Dashboard Event Publisher Port
|
||||
*
|
||||
* Defines the interface for publishing dashboard-related events.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dashboard accessed event
|
||||
*/
|
||||
export interface DashboardAccessedEvent {
|
||||
type: 'dashboard_accessed';
|
||||
driverId: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard error event
|
||||
*/
|
||||
export interface DashboardErrorEvent {
|
||||
type: 'dashboard_error';
|
||||
driverId: string;
|
||||
error: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Event Publisher Interface
|
||||
*
|
||||
* Publishes events related to dashboard operations.
|
||||
*/
|
||||
export interface DashboardEventPublisher {
|
||||
/**
|
||||
* Publish a dashboard accessed event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a dashboard error event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishDashboardError(event: DashboardErrorEvent): Promise<void>;
|
||||
}
|
||||
9
core/dashboard/application/ports/DashboardQuery.ts
Normal file
9
core/dashboard/application/ports/DashboardQuery.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Dashboard Query
|
||||
*
|
||||
* Query object for fetching dashboard data.
|
||||
*/
|
||||
|
||||
export interface DashboardQuery {
|
||||
driverId: string;
|
||||
}
|
||||
107
core/dashboard/application/ports/DashboardRepository.ts
Normal file
107
core/dashboard/application/ports/DashboardRepository.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Dashboard Repository Port
|
||||
*
|
||||
* Defines the interface for accessing dashboard-related data.
|
||||
* This is a read-only repository for dashboard data aggregation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Driver data for dashboard display
|
||||
*/
|
||||
export interface DriverData {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
rating: number;
|
||||
rank: number;
|
||||
starts: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
leagues: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race data for upcoming races section
|
||||
*/
|
||||
export interface RaceData {
|
||||
id: string;
|
||||
trackName: string;
|
||||
carType: string;
|
||||
scheduledDate: Date;
|
||||
timeUntilRace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* League standing data for championship standings section
|
||||
*/
|
||||
export interface LeagueStandingData {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
totalDrivers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity data for recent activity feed
|
||||
*/
|
||||
export interface ActivityData {
|
||||
id: string;
|
||||
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
status: 'success' | 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Friend data for social section
|
||||
*/
|
||||
export interface FriendData {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Repository Interface
|
||||
*
|
||||
* Provides access to all data needed for the dashboard.
|
||||
* Each method returns data for a specific driver.
|
||||
*/
|
||||
export interface DashboardRepository {
|
||||
/**
|
||||
* Find a driver by ID
|
||||
* @param driverId - The driver ID
|
||||
* @returns Driver data or null if not found
|
||||
*/
|
||||
findDriverById(driverId: string): Promise<DriverData | null>;
|
||||
|
||||
/**
|
||||
* Get upcoming races for a driver
|
||||
* @param driverId - The driver ID
|
||||
* @returns Array of upcoming races
|
||||
*/
|
||||
getUpcomingRaces(driverId: string): Promise<RaceData[]>;
|
||||
|
||||
/**
|
||||
* Get league standings for a driver
|
||||
* @param driverId - The driver ID
|
||||
* @returns Array of league standings
|
||||
*/
|
||||
getLeagueStandings(driverId: string): Promise<LeagueStandingData[]>;
|
||||
|
||||
/**
|
||||
* Get recent activity for a driver
|
||||
* @param driverId - The driver ID
|
||||
* @returns Array of recent activities
|
||||
*/
|
||||
getRecentActivity(driverId: string): Promise<ActivityData[]>;
|
||||
|
||||
/**
|
||||
* Get friends for a driver
|
||||
* @param driverId - The driver ID
|
||||
* @returns Array of friends
|
||||
*/
|
||||
getFriends(driverId: string): Promise<FriendData[]>;
|
||||
}
|
||||
18
core/dashboard/application/presenters/DashboardPresenter.ts
Normal file
18
core/dashboard/application/presenters/DashboardPresenter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Dashboard Presenter
|
||||
*
|
||||
* Transforms dashboard data into DTO format for presentation.
|
||||
*/
|
||||
|
||||
import { DashboardDTO } from '../dto/DashboardDTO';
|
||||
|
||||
export class DashboardPresenter {
|
||||
/**
|
||||
* Present dashboard data as DTO
|
||||
* @param data - Dashboard data
|
||||
* @returns Dashboard DTO
|
||||
*/
|
||||
present(data: DashboardDTO): DashboardDTO {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
130
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
130
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Get Dashboard Use Case
|
||||
*
|
||||
* Orchestrates the retrieval of dashboard data for a driver.
|
||||
* Aggregates data from multiple repositories and returns a unified dashboard view.
|
||||
*/
|
||||
|
||||
import { DashboardRepository } from '../ports/DashboardRepository';
|
||||
import { DashboardQuery } from '../ports/DashboardQuery';
|
||||
import { DashboardDTO } from '../dto/DashboardDTO';
|
||||
import { DashboardEventPublisher } from '../ports/DashboardEventPublisher';
|
||||
import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
|
||||
export interface GetDashboardUseCasePorts {
|
||||
driverRepository: DashboardRepository;
|
||||
raceRepository: DashboardRepository;
|
||||
leagueRepository: DashboardRepository;
|
||||
activityRepository: DashboardRepository;
|
||||
eventPublisher: DashboardEventPublisher;
|
||||
}
|
||||
|
||||
export class GetDashboardUseCase {
|
||||
constructor(private readonly ports: GetDashboardUseCasePorts) {}
|
||||
|
||||
async execute(query: DashboardQuery): Promise<DashboardDTO> {
|
||||
// Validate input
|
||||
this.validateQuery(query);
|
||||
|
||||
// Find driver
|
||||
const driver = await this.ports.driverRepository.findDriverById(query.driverId);
|
||||
if (!driver) {
|
||||
throw new DriverNotFoundError(query.driverId);
|
||||
}
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [upcomingRaces, leagueStandings, recentActivity] = await Promise.all([
|
||||
this.ports.raceRepository.getUpcomingRaces(query.driverId),
|
||||
this.ports.leagueRepository.getLeagueStandings(query.driverId),
|
||||
this.ports.activityRepository.getRecentActivity(query.driverId),
|
||||
]);
|
||||
|
||||
// Limit upcoming races to 3
|
||||
const limitedRaces = upcomingRaces
|
||||
.sort((a, b) => a.scheduledDate.getTime() - b.scheduledDate.getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
// Sort recent activity by timestamp (newest first)
|
||||
const sortedActivity = recentActivity
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
// Transform to DTO
|
||||
const driverDto: DashboardDTO['driver'] = {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
};
|
||||
if (driver.avatar) {
|
||||
driverDto.avatar = driver.avatar;
|
||||
}
|
||||
|
||||
const result: DashboardDTO = {
|
||||
driver: driverDto,
|
||||
statistics: {
|
||||
rating: driver.rating,
|
||||
rank: driver.rank,
|
||||
starts: driver.starts,
|
||||
wins: driver.wins,
|
||||
podiums: driver.podiums,
|
||||
leagues: driver.leagues,
|
||||
},
|
||||
upcomingRaces: limitedRaces.map(race => ({
|
||||
trackName: race.trackName,
|
||||
carType: race.carType,
|
||||
scheduledDate: race.scheduledDate.toISOString(),
|
||||
timeUntilRace: race.timeUntilRace || this.calculateTimeUntilRace(race.scheduledDate),
|
||||
})),
|
||||
championshipStandings: leagueStandings.map(standing => ({
|
||||
leagueName: standing.leagueName,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
totalDrivers: standing.totalDrivers,
|
||||
})),
|
||||
recentActivity: sortedActivity.map(activity => ({
|
||||
type: activity.type,
|
||||
description: activity.description,
|
||||
timestamp: activity.timestamp.toISOString(),
|
||||
status: activity.status,
|
||||
})),
|
||||
};
|
||||
|
||||
// Publish event
|
||||
await this.ports.eventPublisher.publishDashboardAccessed({
|
||||
type: 'dashboard_accessed',
|
||||
driverId: query.driverId,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private validateQuery(query: DashboardQuery): void {
|
||||
if (!query.driverId || typeof query.driverId !== 'string') {
|
||||
throw new ValidationError('Driver ID must be a valid string');
|
||||
}
|
||||
if (query.driverId.trim().length === 0) {
|
||||
throw new ValidationError('Driver ID cannot be empty');
|
||||
}
|
||||
}
|
||||
|
||||
private calculateTimeUntilRace(scheduledDate: Date): string {
|
||||
const now = new Date();
|
||||
const diff = scheduledDate.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
return 'Race started';
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days > 1 ? 's' : ''} ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''} ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
16
core/dashboard/domain/errors/DriverNotFoundError.ts
Normal file
16
core/dashboard/domain/errors/DriverNotFoundError.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Driver Not Found Error
|
||||
*
|
||||
* Thrown when a driver with the specified ID cannot be found.
|
||||
*/
|
||||
|
||||
export class DriverNotFoundError extends Error {
|
||||
readonly type = 'domain';
|
||||
readonly context = 'dashboard';
|
||||
readonly kind = 'not_found';
|
||||
|
||||
constructor(driverId: string) {
|
||||
super(`Driver with ID "${driverId}" not found`);
|
||||
this.name = 'DriverNotFoundError';
|
||||
}
|
||||
}
|
||||
54
core/health/ports/HealthCheckQuery.ts
Normal file
54
core/health/ports/HealthCheckQuery.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Health Check Query Port
|
||||
*
|
||||
* Defines the interface for querying health status.
|
||||
* This port is implemented by adapters that can perform health checks.
|
||||
*/
|
||||
|
||||
export interface HealthCheckQuery {
|
||||
/**
|
||||
* Perform a health check
|
||||
*/
|
||||
performHealthCheck(): Promise<HealthCheckResult>;
|
||||
|
||||
/**
|
||||
* Get current connection status
|
||||
*/
|
||||
getStatus(): ConnectionStatus;
|
||||
|
||||
/**
|
||||
* Get detailed health information
|
||||
*/
|
||||
getHealth(): ConnectionHealth;
|
||||
|
||||
/**
|
||||
* Get reliability percentage
|
||||
*/
|
||||
getReliability(): number;
|
||||
|
||||
/**
|
||||
* Check if API is currently available
|
||||
*/
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'degraded' | 'checking';
|
||||
|
||||
export interface ConnectionHealth {
|
||||
status: ConnectionStatus;
|
||||
lastCheck: Date | null;
|
||||
lastSuccess: Date | null;
|
||||
lastFailure: Date | null;
|
||||
consecutiveFailures: number;
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageResponseTime: number;
|
||||
}
|
||||
|
||||
export interface HealthCheckResult {
|
||||
healthy: boolean;
|
||||
responseTime: number;
|
||||
error?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
80
core/health/ports/HealthEventPublisher.ts
Normal file
80
core/health/ports/HealthEventPublisher.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Health Event Publisher Port
|
||||
*
|
||||
* Defines the interface for publishing health-related events.
|
||||
* This port is implemented by adapters that can publish events.
|
||||
*/
|
||||
|
||||
export interface HealthEventPublisher {
|
||||
/**
|
||||
* Publish a health check completed event
|
||||
*/
|
||||
publishHealthCheckCompleted(event: HealthCheckCompletedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a health check failed event
|
||||
*/
|
||||
publishHealthCheckFailed(event: HealthCheckFailedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a health check timeout event
|
||||
*/
|
||||
publishHealthCheckTimeout(event: HealthCheckTimeoutEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a connected event
|
||||
*/
|
||||
publishConnected(event: ConnectedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a disconnected event
|
||||
*/
|
||||
publishDisconnected(event: DisconnectedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a degraded event
|
||||
*/
|
||||
publishDegraded(event: DegradedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a checking event
|
||||
*/
|
||||
publishChecking(event: CheckingEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HealthCheckCompletedEvent {
|
||||
healthy: boolean;
|
||||
responseTime: number;
|
||||
timestamp: Date;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckFailedEvent {
|
||||
error: string;
|
||||
timestamp: Date;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckTimeoutEvent {
|
||||
timestamp: Date;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface ConnectedEvent {
|
||||
timestamp: Date;
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
export interface DisconnectedEvent {
|
||||
timestamp: Date;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
export interface DegradedEvent {
|
||||
timestamp: Date;
|
||||
reliability: number;
|
||||
}
|
||||
|
||||
export interface CheckingEvent {
|
||||
timestamp: Date;
|
||||
}
|
||||
62
core/health/use-cases/CheckApiHealthUseCase.ts
Normal file
62
core/health/use-cases/CheckApiHealthUseCase.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* CheckApiHealthUseCase
|
||||
*
|
||||
* Executes health checks and returns status.
|
||||
* This Use Case orchestrates the health check process and emits events.
|
||||
*/
|
||||
|
||||
import { HealthCheckQuery, HealthCheckResult } from '../ports/HealthCheckQuery';
|
||||
import { HealthEventPublisher } from '../ports/HealthEventPublisher';
|
||||
|
||||
export interface CheckApiHealthUseCasePorts {
|
||||
healthCheckAdapter: HealthCheckQuery;
|
||||
eventPublisher: HealthEventPublisher;
|
||||
}
|
||||
|
||||
export class CheckApiHealthUseCase {
|
||||
constructor(private readonly ports: CheckApiHealthUseCasePorts) {}
|
||||
|
||||
/**
|
||||
* Execute a health check
|
||||
*/
|
||||
async execute(): Promise<HealthCheckResult> {
|
||||
const { healthCheckAdapter, eventPublisher } = this.ports;
|
||||
|
||||
try {
|
||||
// Perform the health check
|
||||
const result = await healthCheckAdapter.performHealthCheck();
|
||||
|
||||
// Emit appropriate event based on result
|
||||
if (result.healthy) {
|
||||
await eventPublisher.publishHealthCheckCompleted({
|
||||
healthy: result.healthy,
|
||||
responseTime: result.responseTime,
|
||||
timestamp: result.timestamp,
|
||||
});
|
||||
} else {
|
||||
await eventPublisher.publishHealthCheckFailed({
|
||||
error: result.error || 'Unknown error',
|
||||
timestamp: result.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const timestamp = new Date();
|
||||
|
||||
// Emit failed event
|
||||
await eventPublisher.publishHealthCheckFailed({
|
||||
error: errorMessage,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
healthy: false,
|
||||
responseTime: 0,
|
||||
error: errorMessage,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
52
core/health/use-cases/GetConnectionStatusUseCase.ts
Normal file
52
core/health/use-cases/GetConnectionStatusUseCase.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* GetConnectionStatusUseCase
|
||||
*
|
||||
* Retrieves current connection status and metrics.
|
||||
* This Use Case orchestrates the retrieval of connection status information.
|
||||
*/
|
||||
|
||||
import { HealthCheckQuery, ConnectionHealth, ConnectionStatus } from '../ports/HealthCheckQuery';
|
||||
|
||||
export interface GetConnectionStatusUseCasePorts {
|
||||
healthCheckAdapter: HealthCheckQuery;
|
||||
}
|
||||
|
||||
export interface ConnectionStatusResult {
|
||||
status: ConnectionStatus;
|
||||
reliability: number;
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
consecutiveFailures: number;
|
||||
averageResponseTime: number;
|
||||
lastCheck: Date | null;
|
||||
lastSuccess: Date | null;
|
||||
lastFailure: Date | null;
|
||||
}
|
||||
|
||||
export class GetConnectionStatusUseCase {
|
||||
constructor(private readonly ports: GetConnectionStatusUseCasePorts) {}
|
||||
|
||||
/**
|
||||
* Execute to get current connection status
|
||||
*/
|
||||
async execute(): Promise<ConnectionStatusResult> {
|
||||
const { healthCheckAdapter } = this.ports;
|
||||
|
||||
const health = healthCheckAdapter.getHealth();
|
||||
const reliability = healthCheckAdapter.getReliability();
|
||||
|
||||
return {
|
||||
status: health.status,
|
||||
reliability,
|
||||
totalRequests: health.totalRequests,
|
||||
successfulRequests: health.successfulRequests,
|
||||
failedRequests: health.failedRequests,
|
||||
consecutiveFailures: health.consecutiveFailures,
|
||||
averageResponseTime: health.averageResponseTime,
|
||||
lastCheck: health.lastCheck,
|
||||
lastSuccess: health.lastSuccess,
|
||||
lastFailure: health.lastFailure,
|
||||
};
|
||||
}
|
||||
}
|
||||
77
core/leaderboards/application/ports/DriverRankingsQuery.ts
Normal file
77
core/leaderboards/application/ports/DriverRankingsQuery.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Driver Rankings Query Port
|
||||
*
|
||||
* Defines the interface for querying driver rankings data.
|
||||
* This is a read-only query with search, filter, and sort capabilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Query input for driver rankings
|
||||
*/
|
||||
export interface DriverRankingsQuery {
|
||||
/**
|
||||
* Search term for filtering drivers by name (case-insensitive)
|
||||
*/
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Minimum rating filter
|
||||
*/
|
||||
minRating?: number;
|
||||
|
||||
/**
|
||||
* Filter by team ID
|
||||
*/
|
||||
teamId?: string;
|
||||
|
||||
/**
|
||||
* Sort field (default: rating)
|
||||
*/
|
||||
sortBy?: 'rating' | 'name' | 'rank' | 'raceCount';
|
||||
|
||||
/**
|
||||
* Sort order (default: desc)
|
||||
*/
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Page number (default: 1)
|
||||
*/
|
||||
page?: number;
|
||||
|
||||
/**
|
||||
* Number of results per page (default: 20)
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver entry for rankings
|
||||
*/
|
||||
export interface DriverRankingEntry {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata
|
||||
*/
|
||||
export interface PaginationMetadata {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver rankings result
|
||||
*/
|
||||
export interface DriverRankingsResult {
|
||||
drivers: DriverRankingEntry[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Global Leaderboards Query Port
|
||||
*
|
||||
* Defines the interface for querying global leaderboards data.
|
||||
* This is a read-only query for retrieving top drivers and teams.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Query input for global leaderboards
|
||||
*/
|
||||
export interface GlobalLeaderboardsQuery {
|
||||
/**
|
||||
* Maximum number of drivers to return (default: 10)
|
||||
*/
|
||||
driverLimit?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of teams to return (default: 10)
|
||||
*/
|
||||
teamLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver entry for global leaderboards
|
||||
*/
|
||||
export interface GlobalLeaderboardDriverEntry {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team entry for global leaderboards
|
||||
*/
|
||||
export interface GlobalLeaderboardTeamEntry {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
memberCount: number;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global leaderboards result
|
||||
*/
|
||||
export interface GlobalLeaderboardsResult {
|
||||
drivers: GlobalLeaderboardDriverEntry[];
|
||||
teams: GlobalLeaderboardTeamEntry[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Leaderboards Event Publisher Port
|
||||
*
|
||||
* Defines the interface for publishing leaderboards-related events.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global leaderboards accessed event
|
||||
*/
|
||||
export interface GlobalLeaderboardsAccessedEvent {
|
||||
type: 'global_leaderboards_accessed';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver rankings accessed event
|
||||
*/
|
||||
export interface DriverRankingsAccessedEvent {
|
||||
type: 'driver_rankings_accessed';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team rankings accessed event
|
||||
*/
|
||||
export interface TeamRankingsAccessedEvent {
|
||||
type: 'team_rankings_accessed';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaderboards error event
|
||||
*/
|
||||
export interface LeaderboardsErrorEvent {
|
||||
type: 'leaderboards_error';
|
||||
error: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaderboards Event Publisher Interface
|
||||
*
|
||||
* Publishes events related to leaderboards operations.
|
||||
*/
|
||||
export interface LeaderboardsEventPublisher {
|
||||
/**
|
||||
* Publish a global leaderboards accessed event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishGlobalLeaderboardsAccessed(event: GlobalLeaderboardsAccessedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a driver rankings accessed event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishDriverRankingsAccessed(event: DriverRankingsAccessedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a team rankings accessed event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishTeamRankingsAccessed(event: TeamRankingsAccessedEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Publish a leaderboards error event
|
||||
* @param event - The event to publish
|
||||
*/
|
||||
publishLeaderboardsError(event: LeaderboardsErrorEvent): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Leaderboards Repository Port
|
||||
*
|
||||
* Defines the interface for accessing leaderboards-related data.
|
||||
* This is a read-only repository for leaderboards data aggregation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Driver data for leaderboards
|
||||
*/
|
||||
export interface LeaderboardDriverData {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team data for leaderboards
|
||||
*/
|
||||
export interface LeaderboardTeamData {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
memberCount: number;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaderboards Repository Interface
|
||||
*
|
||||
* Provides access to all data needed for leaderboards.
|
||||
*/
|
||||
export interface LeaderboardsRepository {
|
||||
/**
|
||||
* Find all drivers for leaderboards
|
||||
* @returns Array of driver data
|
||||
*/
|
||||
findAllDrivers(): Promise<LeaderboardDriverData[]>;
|
||||
|
||||
/**
|
||||
* Find all teams for leaderboards
|
||||
* @returns Array of team data
|
||||
*/
|
||||
findAllTeams(): Promise<LeaderboardTeamData[]>;
|
||||
|
||||
/**
|
||||
* Find drivers by team ID
|
||||
* @param teamId - The team ID
|
||||
* @returns Array of driver data
|
||||
*/
|
||||
findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]>;
|
||||
}
|
||||
76
core/leaderboards/application/ports/TeamRankingsQuery.ts
Normal file
76
core/leaderboards/application/ports/TeamRankingsQuery.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Team Rankings Query Port
|
||||
*
|
||||
* Defines the interface for querying team rankings data.
|
||||
* This is a read-only query with search, filter, and sort capabilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Query input for team rankings
|
||||
*/
|
||||
export interface TeamRankingsQuery {
|
||||
/**
|
||||
* Search term for filtering teams by name (case-insensitive)
|
||||
*/
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Minimum rating filter
|
||||
*/
|
||||
minRating?: number;
|
||||
|
||||
/**
|
||||
* Minimum member count filter
|
||||
*/
|
||||
minMemberCount?: number;
|
||||
|
||||
/**
|
||||
* Sort field (default: rating)
|
||||
*/
|
||||
sortBy?: 'rating' | 'name' | 'rank' | 'memberCount';
|
||||
|
||||
/**
|
||||
* Sort order (default: desc)
|
||||
*/
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Page number (default: 1)
|
||||
*/
|
||||
page?: number;
|
||||
|
||||
/**
|
||||
* Number of results per page (default: 20)
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team entry for rankings
|
||||
*/
|
||||
export interface TeamRankingEntry {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
memberCount: number;
|
||||
raceCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata
|
||||
*/
|
||||
export interface PaginationMetadata {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team rankings result
|
||||
*/
|
||||
export interface TeamRankingsResult {
|
||||
teams: TeamRankingEntry[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Get Driver Rankings Use Case
|
||||
*
|
||||
* Orchestrates the retrieval of driver rankings data.
|
||||
* Aggregates data from repositories and returns drivers with search, filter, and sort capabilities.
|
||||
*/
|
||||
|
||||
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
|
||||
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
|
||||
import {
|
||||
DriverRankingsQuery,
|
||||
DriverRankingsResult,
|
||||
DriverRankingEntry,
|
||||
PaginationMetadata,
|
||||
} from '../ports/DriverRankingsQuery';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
|
||||
export interface GetDriverRankingsUseCasePorts {
|
||||
leaderboardsRepository: LeaderboardsRepository;
|
||||
eventPublisher: LeaderboardsEventPublisher;
|
||||
}
|
||||
|
||||
export class GetDriverRankingsUseCase {
|
||||
constructor(private readonly ports: GetDriverRankingsUseCasePorts) {}
|
||||
|
||||
async execute(query: DriverRankingsQuery = {}): Promise<DriverRankingsResult> {
|
||||
try {
|
||||
// Validate query parameters
|
||||
this.validateQuery(query);
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
// Fetch all drivers
|
||||
const allDrivers = await this.ports.leaderboardsRepository.findAllDrivers();
|
||||
|
||||
// Apply search filter
|
||||
let filteredDrivers = allDrivers;
|
||||
if (query.search) {
|
||||
const searchLower = query.search.toLowerCase();
|
||||
filteredDrivers = filteredDrivers.filter((driver) =>
|
||||
driver.name.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply rating filter
|
||||
if (query.minRating !== undefined) {
|
||||
filteredDrivers = filteredDrivers.filter(
|
||||
(driver) => driver.rating >= query.minRating!,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply team filter
|
||||
if (query.teamId) {
|
||||
filteredDrivers = filteredDrivers.filter(
|
||||
(driver) => driver.teamId === query.teamId,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort drivers
|
||||
const sortBy = query.sortBy ?? 'rating';
|
||||
const sortOrder = query.sortOrder ?? 'desc';
|
||||
|
||||
filteredDrivers.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'rating':
|
||||
comparison = a.rating - b.rating;
|
||||
break;
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'rank':
|
||||
comparison = 0;
|
||||
break;
|
||||
case 'raceCount':
|
||||
comparison = a.raceCount - b.raceCount;
|
||||
break;
|
||||
}
|
||||
|
||||
// If primary sort is equal, always use name ASC as secondary sort
|
||||
if (comparison === 0 && sortBy !== 'name') {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
// Secondary sort should not be affected by sortOrder of primary field?
|
||||
// Actually, usually secondary sort is always ASC or follows primary.
|
||||
// Let's keep it simple: if primary is equal, use name ASC.
|
||||
return comparison;
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const total = filteredDrivers.length;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = Math.min(startIndex + limit, total);
|
||||
|
||||
// Get paginated drivers
|
||||
const paginatedDrivers = filteredDrivers.slice(startIndex, endIndex);
|
||||
|
||||
// Map to ranking entries with rank
|
||||
const driverEntries: DriverRankingEntry[] = paginatedDrivers.map(
|
||||
(driver, index): DriverRankingEntry => ({
|
||||
rank: startIndex + index + 1,
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating: driver.rating,
|
||||
...(driver.teamId !== undefined && { teamId: driver.teamId }),
|
||||
...(driver.teamName !== undefined && { teamName: driver.teamName }),
|
||||
raceCount: driver.raceCount,
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish event
|
||||
await this.ports.eventPublisher.publishDriverRankingsAccessed({
|
||||
type: 'driver_rankings_accessed',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
drivers: driverEntries,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Publish error event
|
||||
await this.ports.eventPublisher.publishLeaderboardsError({
|
||||
type: 'leaderboards_error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateQuery(query: DriverRankingsQuery): void {
|
||||
if (query.page !== undefined && query.page < 1) {
|
||||
throw new ValidationError('Page must be a positive integer');
|
||||
}
|
||||
|
||||
if (query.limit !== undefined && query.limit < 1) {
|
||||
throw new ValidationError('Limit must be a positive integer');
|
||||
}
|
||||
|
||||
if (query.minRating !== undefined && query.minRating < 0) {
|
||||
throw new ValidationError('Min rating must be a non-negative number');
|
||||
}
|
||||
|
||||
if (query.sortBy && !['rating', 'name', 'rank', 'raceCount'].includes(query.sortBy)) {
|
||||
throw new ValidationError('Invalid sort field');
|
||||
}
|
||||
|
||||
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
|
||||
throw new ValidationError('Sort order must be "asc" or "desc"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Get Global Leaderboards Use Case
|
||||
*
|
||||
* Orchestrates the retrieval of global leaderboards data.
|
||||
* Aggregates data from repositories and returns top drivers and teams.
|
||||
*/
|
||||
|
||||
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
|
||||
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
|
||||
import {
|
||||
GlobalLeaderboardsQuery,
|
||||
GlobalLeaderboardsResult,
|
||||
GlobalLeaderboardDriverEntry,
|
||||
GlobalLeaderboardTeamEntry,
|
||||
} from '../ports/GlobalLeaderboardsQuery';
|
||||
|
||||
export interface GetGlobalLeaderboardsUseCasePorts {
|
||||
leaderboardsRepository: LeaderboardsRepository;
|
||||
eventPublisher: LeaderboardsEventPublisher;
|
||||
}
|
||||
|
||||
export class GetGlobalLeaderboardsUseCase {
|
||||
constructor(private readonly ports: GetGlobalLeaderboardsUseCasePorts) {}
|
||||
|
||||
async execute(query: GlobalLeaderboardsQuery = {}): Promise<GlobalLeaderboardsResult> {
|
||||
try {
|
||||
const driverLimit = query.driverLimit ?? 10;
|
||||
const teamLimit = query.teamLimit ?? 10;
|
||||
|
||||
// Fetch all drivers and teams in parallel
|
||||
const [allDrivers, allTeams] = await Promise.all([
|
||||
this.ports.leaderboardsRepository.findAllDrivers(),
|
||||
this.ports.leaderboardsRepository.findAllTeams(),
|
||||
]);
|
||||
|
||||
// Sort drivers by rating (highest first) and take top N
|
||||
const topDrivers = allDrivers
|
||||
.sort((a, b) => {
|
||||
const ratingComparison = b.rating - a.rating;
|
||||
if (ratingComparison === 0) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return ratingComparison;
|
||||
})
|
||||
.slice(0, driverLimit)
|
||||
.map((driver, index): GlobalLeaderboardDriverEntry => ({
|
||||
rank: index + 1,
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating: driver.rating,
|
||||
...(driver.teamId !== undefined && { teamId: driver.teamId }),
|
||||
...(driver.teamName !== undefined && { teamName: driver.teamName }),
|
||||
raceCount: driver.raceCount,
|
||||
}));
|
||||
|
||||
// Sort teams by rating (highest first) and take top N
|
||||
const topTeams = allTeams
|
||||
.sort((a, b) => {
|
||||
const ratingComparison = b.rating - a.rating;
|
||||
if (ratingComparison === 0) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return ratingComparison;
|
||||
})
|
||||
.slice(0, teamLimit)
|
||||
.map((team, index): GlobalLeaderboardTeamEntry => ({
|
||||
rank: index + 1,
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
rating: team.rating,
|
||||
memberCount: team.memberCount,
|
||||
raceCount: team.raceCount,
|
||||
}));
|
||||
|
||||
// Publish event
|
||||
await this.ports.eventPublisher.publishGlobalLeaderboardsAccessed({
|
||||
type: 'global_leaderboards_accessed',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
drivers: topDrivers,
|
||||
teams: topTeams,
|
||||
};
|
||||
} catch (error) {
|
||||
// Publish error event
|
||||
await this.ports.eventPublisher.publishLeaderboardsError({
|
||||
type: 'leaderboards_error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Get Team Rankings Use Case
|
||||
*
|
||||
* Orchestrates the retrieval of team rankings data.
|
||||
* Aggregates data from repositories and returns teams with search, filter, and sort capabilities.
|
||||
*/
|
||||
|
||||
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
|
||||
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
|
||||
import {
|
||||
TeamRankingsQuery,
|
||||
TeamRankingsResult,
|
||||
TeamRankingEntry,
|
||||
PaginationMetadata,
|
||||
} from '../ports/TeamRankingsQuery';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
|
||||
export interface GetTeamRankingsUseCasePorts {
|
||||
leaderboardsRepository: LeaderboardsRepository;
|
||||
eventPublisher: LeaderboardsEventPublisher;
|
||||
}
|
||||
|
||||
export class GetTeamRankingsUseCase {
|
||||
constructor(private readonly ports: GetTeamRankingsUseCasePorts) {}
|
||||
|
||||
async execute(query: TeamRankingsQuery = {}): Promise<TeamRankingsResult> {
|
||||
try {
|
||||
// Validate query parameters
|
||||
this.validateQuery(query);
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
// Fetch all teams and drivers for member count aggregation
|
||||
const [allTeams, allDrivers] = await Promise.all([
|
||||
this.ports.leaderboardsRepository.findAllTeams(),
|
||||
this.ports.leaderboardsRepository.findAllDrivers(),
|
||||
]);
|
||||
|
||||
// Count members from drivers
|
||||
const driverCounts = new Map<string, number>();
|
||||
allDrivers.forEach(driver => {
|
||||
if (driver.teamId) {
|
||||
driverCounts.set(driver.teamId, (driverCounts.get(driver.teamId) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Map teams from repository
|
||||
const teamsWithAggregatedData = allTeams.map(team => {
|
||||
const countFromDrivers = driverCounts.get(team.id);
|
||||
return {
|
||||
...team,
|
||||
// If drivers exist in repository for this team, use that count as source of truth.
|
||||
// Otherwise, fall back to the memberCount property on the team itself.
|
||||
memberCount: countFromDrivers !== undefined ? countFromDrivers : (team.memberCount || 0)
|
||||
};
|
||||
});
|
||||
|
||||
// Discover teams that only exist in the drivers repository
|
||||
const discoveredTeams: any[] = [];
|
||||
driverCounts.forEach((count, teamId) => {
|
||||
if (!allTeams.some(t => t.id === teamId)) {
|
||||
const driverWithTeam = allDrivers.find(d => d.teamId === teamId);
|
||||
discoveredTeams.push({
|
||||
id: teamId,
|
||||
name: driverWithTeam?.teamName || `Team ${teamId}`,
|
||||
rating: 0,
|
||||
memberCount: count,
|
||||
raceCount: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const finalTeams = [...teamsWithAggregatedData, ...discoveredTeams];
|
||||
|
||||
// Apply search filter
|
||||
let filteredTeams = finalTeams;
|
||||
if (query.search) {
|
||||
const searchLower = query.search.toLowerCase();
|
||||
filteredTeams = filteredTeams.filter((team) =>
|
||||
team.name.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply rating filter
|
||||
if (query.minRating !== undefined) {
|
||||
filteredTeams = filteredTeams.filter(
|
||||
(team) => team.rating >= query.minRating!,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply member count filter
|
||||
if (query.minMemberCount !== undefined) {
|
||||
filteredTeams = filteredTeams.filter(
|
||||
(team) => team.memberCount >= query.minMemberCount!,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort teams
|
||||
const sortBy = query.sortBy ?? 'rating';
|
||||
const sortOrder = query.sortOrder ?? 'desc';
|
||||
|
||||
filteredTeams.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'rating':
|
||||
comparison = a.rating - b.rating;
|
||||
break;
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'rank':
|
||||
comparison = 0;
|
||||
break;
|
||||
case 'memberCount':
|
||||
comparison = a.memberCount - b.memberCount;
|
||||
break;
|
||||
}
|
||||
|
||||
// If primary sort is equal, always use name ASC as secondary sort
|
||||
if (comparison === 0 && sortBy !== 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const total = filteredTeams.length;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = Math.min(startIndex + limit, total);
|
||||
|
||||
// Get paginated teams
|
||||
const paginatedTeams = filteredTeams.slice(startIndex, endIndex);
|
||||
|
||||
// Map to ranking entries with rank
|
||||
const teamEntries: TeamRankingEntry[] = paginatedTeams.map(
|
||||
(team, index): TeamRankingEntry => ({
|
||||
rank: startIndex + index + 1,
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
rating: team.rating,
|
||||
memberCount: team.memberCount,
|
||||
raceCount: team.raceCount,
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish event
|
||||
await this.ports.eventPublisher.publishTeamRankingsAccessed({
|
||||
type: 'team_rankings_accessed',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
teams: teamEntries,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Publish error event
|
||||
await this.ports.eventPublisher.publishLeaderboardsError({
|
||||
type: 'leaderboards_error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateQuery(query: TeamRankingsQuery): void {
|
||||
if (query.page !== undefined && query.page < 1) {
|
||||
throw new ValidationError('Page must be a positive integer');
|
||||
}
|
||||
|
||||
if (query.limit !== undefined && query.limit < 1) {
|
||||
throw new ValidationError('Limit must be a positive integer');
|
||||
}
|
||||
|
||||
if (query.minRating !== undefined && query.minRating < 0) {
|
||||
throw new ValidationError('Min rating must be a non-negative number');
|
||||
}
|
||||
|
||||
if (query.minMemberCount !== undefined && query.minMemberCount < 0) {
|
||||
throw new ValidationError('Min member count must be a non-negative number');
|
||||
}
|
||||
|
||||
if (query.sortBy && !['rating', 'name', 'rank', 'memberCount'].includes(query.sortBy)) {
|
||||
throw new ValidationError('Invalid sort field');
|
||||
}
|
||||
|
||||
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
|
||||
throw new ValidationError('Sort order must be "asc" or "desc"');
|
||||
}
|
||||
}
|
||||
}
|
||||
33
core/leagues/application/ports/LeagueCreateCommand.ts
Normal file
33
core/leagues/application/ports/LeagueCreateCommand.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface LeagueCreateCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private';
|
||||
ownerId: string;
|
||||
|
||||
// Structure
|
||||
maxDrivers?: number;
|
||||
approvalRequired: boolean;
|
||||
lateJoinAllowed: boolean;
|
||||
|
||||
// Schedule
|
||||
raceFrequency?: string;
|
||||
raceDay?: string;
|
||||
raceTime?: string;
|
||||
tracks?: string[];
|
||||
|
||||
// Scoring
|
||||
scoringSystem?: any;
|
||||
bonusPointsEnabled: boolean;
|
||||
penaltiesEnabled: boolean;
|
||||
|
||||
// Stewarding
|
||||
protestsEnabled: boolean;
|
||||
appealsEnabled: boolean;
|
||||
stewardTeam?: string[];
|
||||
|
||||
// Tags
|
||||
gameType?: string;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
40
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
40
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface LeagueCreatedEvent {
|
||||
type: 'LeagueCreatedEvent';
|
||||
leagueId: string;
|
||||
ownerId: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface LeagueUpdatedEvent {
|
||||
type: 'LeagueUpdatedEvent';
|
||||
leagueId: string;
|
||||
updates: Partial<any>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface LeagueDeletedEvent {
|
||||
type: 'LeagueDeletedEvent';
|
||||
leagueId: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface LeagueAccessedEvent {
|
||||
type: 'LeagueAccessedEvent';
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface LeagueEventPublisher {
|
||||
emitLeagueCreated(event: LeagueCreatedEvent): Promise<void>;
|
||||
emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void>;
|
||||
emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void>;
|
||||
emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void>;
|
||||
|
||||
getLeagueCreatedEventCount(): number;
|
||||
getLeagueUpdatedEventCount(): number;
|
||||
getLeagueDeletedEventCount(): number;
|
||||
getLeagueAccessedEventCount(): number;
|
||||
|
||||
clear(): void;
|
||||
}
|
||||
169
core/leagues/application/ports/LeagueRepository.ts
Normal file
169
core/leagues/application/ports/LeagueRepository.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
export interface LeagueData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: 'public' | 'private';
|
||||
ownerId: string;
|
||||
status: 'active' | 'pending' | 'archived';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Structure
|
||||
maxDrivers: number | null;
|
||||
approvalRequired: boolean;
|
||||
lateJoinAllowed: boolean;
|
||||
|
||||
// Schedule
|
||||
raceFrequency: string | null;
|
||||
raceDay: string | null;
|
||||
raceTime: string | null;
|
||||
tracks: string[] | null;
|
||||
|
||||
// Scoring
|
||||
scoringSystem: any | null;
|
||||
bonusPointsEnabled: boolean;
|
||||
penaltiesEnabled: boolean;
|
||||
|
||||
// Stewarding
|
||||
protestsEnabled: boolean;
|
||||
appealsEnabled: boolean;
|
||||
stewardTeam: string[] | null;
|
||||
|
||||
// Tags
|
||||
gameType: string | null;
|
||||
skillLevel: string | null;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
}
|
||||
|
||||
export interface LeagueStats {
|
||||
leagueId: string;
|
||||
memberCount: number;
|
||||
raceCount: number;
|
||||
sponsorCount: number;
|
||||
prizePool: number;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
}
|
||||
|
||||
export interface LeagueFinancials {
|
||||
leagueId: string;
|
||||
walletBalance: number;
|
||||
totalRevenue: number;
|
||||
totalFees: number;
|
||||
pendingPayouts: number;
|
||||
netBalance: number;
|
||||
}
|
||||
|
||||
export interface LeagueStewardingMetrics {
|
||||
leagueId: string;
|
||||
averageResolutionTime: number;
|
||||
averageProtestResolutionTime: number;
|
||||
averagePenaltyAppealSuccessRate: number;
|
||||
averageProtestSuccessRate: number;
|
||||
averageStewardingActionSuccessRate: number;
|
||||
}
|
||||
|
||||
export interface LeaguePerformanceMetrics {
|
||||
leagueId: string;
|
||||
averageLapTime: number;
|
||||
averageFieldSize: number;
|
||||
averageIncidentCount: number;
|
||||
averagePenaltyCount: number;
|
||||
averageProtestCount: number;
|
||||
averageStewardingActionCount: number;
|
||||
}
|
||||
|
||||
export interface LeagueRatingMetrics {
|
||||
leagueId: string;
|
||||
overallRating: number;
|
||||
ratingTrend: number;
|
||||
rankTrend: number;
|
||||
pointsTrend: number;
|
||||
winRateTrend: number;
|
||||
podiumRateTrend: number;
|
||||
dnfRateTrend: number;
|
||||
}
|
||||
|
||||
export interface LeagueTrendMetrics {
|
||||
leagueId: string;
|
||||
incidentRateTrend: number;
|
||||
penaltyRateTrend: number;
|
||||
protestRateTrend: number;
|
||||
stewardingActionRateTrend: number;
|
||||
stewardingTimeTrend: number;
|
||||
protestResolutionTimeTrend: number;
|
||||
}
|
||||
|
||||
export interface LeagueSuccessRateMetrics {
|
||||
leagueId: string;
|
||||
penaltyAppealSuccessRate: number;
|
||||
protestSuccessRate: number;
|
||||
stewardingActionSuccessRate: number;
|
||||
stewardingActionAppealSuccessRate: number;
|
||||
stewardingActionPenaltySuccessRate: number;
|
||||
stewardingActionProtestSuccessRate: number;
|
||||
}
|
||||
|
||||
export interface LeagueResolutionTimeMetrics {
|
||||
leagueId: string;
|
||||
averageStewardingTime: number;
|
||||
averageProtestResolutionTime: number;
|
||||
averageStewardingActionAppealPenaltyProtestResolutionTime: number;
|
||||
}
|
||||
|
||||
export interface LeagueComplexSuccessRateMetrics {
|
||||
leagueId: string;
|
||||
stewardingActionAppealPenaltyProtestSuccessRate: number;
|
||||
stewardingActionAppealProtestSuccessRate: number;
|
||||
stewardingActionPenaltyProtestSuccessRate: number;
|
||||
stewardingActionAppealPenaltyProtestSuccessRate2: number;
|
||||
}
|
||||
|
||||
export interface LeagueComplexResolutionTimeMetrics {
|
||||
leagueId: string;
|
||||
stewardingActionAppealPenaltyProtestResolutionTime: number;
|
||||
stewardingActionAppealProtestResolutionTime: number;
|
||||
stewardingActionPenaltyProtestResolutionTime: number;
|
||||
stewardingActionAppealPenaltyProtestResolutionTime2: number;
|
||||
}
|
||||
|
||||
export interface LeagueRepository {
|
||||
create(league: LeagueData): Promise<LeagueData>;
|
||||
findById(id: string): Promise<LeagueData | null>;
|
||||
findByName(name: string): Promise<LeagueData | null>;
|
||||
findByOwner(ownerId: string): Promise<LeagueData[]>;
|
||||
search(query: string): Promise<LeagueData[]>;
|
||||
update(id: string, updates: Partial<LeagueData>): Promise<LeagueData>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
getStats(leagueId: string): Promise<LeagueStats>;
|
||||
updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats>;
|
||||
|
||||
getFinancials(leagueId: string): Promise<LeagueFinancials>;
|
||||
updateFinancials(leagueId: string, financials: LeagueFinancials): Promise<LeagueFinancials>;
|
||||
|
||||
getStewardingMetrics(leagueId: string): Promise<LeagueStewardingMetrics>;
|
||||
updateStewardingMetrics(leagueId: string, metrics: LeagueStewardingMetrics): Promise<LeagueStewardingMetrics>;
|
||||
|
||||
getPerformanceMetrics(leagueId: string): Promise<LeaguePerformanceMetrics>;
|
||||
updatePerformanceMetrics(leagueId: string, metrics: LeaguePerformanceMetrics): Promise<LeaguePerformanceMetrics>;
|
||||
|
||||
getRatingMetrics(leagueId: string): Promise<LeagueRatingMetrics>;
|
||||
updateRatingMetrics(leagueId: string, metrics: LeagueRatingMetrics): Promise<LeagueRatingMetrics>;
|
||||
|
||||
getTrendMetrics(leagueId: string): Promise<LeagueTrendMetrics>;
|
||||
updateTrendMetrics(leagueId: string, metrics: LeagueTrendMetrics): Promise<LeagueTrendMetrics>;
|
||||
|
||||
getSuccessRateMetrics(leagueId: string): Promise<LeagueSuccessRateMetrics>;
|
||||
updateSuccessRateMetrics(leagueId: string, metrics: LeagueSuccessRateMetrics): Promise<LeagueSuccessRateMetrics>;
|
||||
|
||||
getResolutionTimeMetrics(leagueId: string): Promise<LeagueResolutionTimeMetrics>;
|
||||
updateResolutionTimeMetrics(leagueId: string, metrics: LeagueResolutionTimeMetrics): Promise<LeagueResolutionTimeMetrics>;
|
||||
|
||||
getComplexSuccessRateMetrics(leagueId: string): Promise<LeagueComplexSuccessRateMetrics>;
|
||||
updateComplexSuccessRateMetrics(leagueId: string, metrics: LeagueComplexSuccessRateMetrics): Promise<LeagueComplexSuccessRateMetrics>;
|
||||
|
||||
getComplexResolutionTimeMetrics(leagueId: string): Promise<LeagueComplexResolutionTimeMetrics>;
|
||||
updateComplexResolutionTimeMetrics(leagueId: string, metrics: LeagueComplexResolutionTimeMetrics): Promise<LeagueComplexResolutionTimeMetrics>;
|
||||
}
|
||||
183
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
183
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
|
||||
import { LeagueEventPublisher, LeagueCreatedEvent } from '../ports/LeagueEventPublisher';
|
||||
import { LeagueCreateCommand } from '../ports/LeagueCreateCommand';
|
||||
|
||||
export class CreateLeagueUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: LeagueRepository,
|
||||
private readonly eventPublisher: LeagueEventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: LeagueCreateCommand): Promise<LeagueData> {
|
||||
// Validate command
|
||||
if (!command.name || command.name.trim() === '') {
|
||||
throw new Error('League name is required');
|
||||
}
|
||||
|
||||
if (!command.ownerId || command.ownerId.trim() === '') {
|
||||
throw new Error('Owner ID is required');
|
||||
}
|
||||
|
||||
if (command.maxDrivers !== undefined && command.maxDrivers < 1) {
|
||||
throw new Error('Max drivers must be at least 1');
|
||||
}
|
||||
|
||||
// Create league data
|
||||
const leagueId = `league-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const now = new Date();
|
||||
|
||||
const leagueData: LeagueData = {
|
||||
id: leagueId,
|
||||
name: command.name,
|
||||
description: command.description || null,
|
||||
visibility: command.visibility,
|
||||
ownerId: command.ownerId,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
maxDrivers: command.maxDrivers || null,
|
||||
approvalRequired: command.approvalRequired,
|
||||
lateJoinAllowed: command.lateJoinAllowed,
|
||||
raceFrequency: command.raceFrequency || null,
|
||||
raceDay: command.raceDay || null,
|
||||
raceTime: command.raceTime || null,
|
||||
tracks: command.tracks || null,
|
||||
scoringSystem: command.scoringSystem || null,
|
||||
bonusPointsEnabled: command.bonusPointsEnabled,
|
||||
penaltiesEnabled: command.penaltiesEnabled,
|
||||
protestsEnabled: command.protestsEnabled,
|
||||
appealsEnabled: command.appealsEnabled,
|
||||
stewardTeam: command.stewardTeam || null,
|
||||
gameType: command.gameType || null,
|
||||
skillLevel: command.skillLevel || null,
|
||||
category: command.category || null,
|
||||
tags: command.tags || null,
|
||||
};
|
||||
|
||||
// Save league to repository
|
||||
const savedLeague = await this.leagueRepository.create(leagueData);
|
||||
|
||||
// Initialize league stats
|
||||
const defaultStats = {
|
||||
leagueId,
|
||||
memberCount: 1,
|
||||
raceCount: 0,
|
||||
sponsorCount: 0,
|
||||
prizePool: 0,
|
||||
rating: 0,
|
||||
reviewCount: 0,
|
||||
};
|
||||
await this.leagueRepository.updateStats(leagueId, defaultStats);
|
||||
|
||||
// Initialize league financials
|
||||
const defaultFinancials = {
|
||||
leagueId,
|
||||
walletBalance: 0,
|
||||
totalRevenue: 0,
|
||||
totalFees: 0,
|
||||
pendingPayouts: 0,
|
||||
netBalance: 0,
|
||||
};
|
||||
await this.leagueRepository.updateFinancials(leagueId, defaultFinancials);
|
||||
|
||||
// Initialize stewarding metrics
|
||||
const defaultStewardingMetrics = {
|
||||
leagueId,
|
||||
averageResolutionTime: 0,
|
||||
averageProtestResolutionTime: 0,
|
||||
averagePenaltyAppealSuccessRate: 0,
|
||||
averageProtestSuccessRate: 0,
|
||||
averageStewardingActionSuccessRate: 0,
|
||||
};
|
||||
await this.leagueRepository.updateStewardingMetrics(leagueId, defaultStewardingMetrics);
|
||||
|
||||
// Initialize performance metrics
|
||||
const defaultPerformanceMetrics = {
|
||||
leagueId,
|
||||
averageLapTime: 0,
|
||||
averageFieldSize: 0,
|
||||
averageIncidentCount: 0,
|
||||
averagePenaltyCount: 0,
|
||||
averageProtestCount: 0,
|
||||
averageStewardingActionCount: 0,
|
||||
};
|
||||
await this.leagueRepository.updatePerformanceMetrics(leagueId, defaultPerformanceMetrics);
|
||||
|
||||
// Initialize rating metrics
|
||||
const defaultRatingMetrics = {
|
||||
leagueId,
|
||||
overallRating: 0,
|
||||
ratingTrend: 0,
|
||||
rankTrend: 0,
|
||||
pointsTrend: 0,
|
||||
winRateTrend: 0,
|
||||
podiumRateTrend: 0,
|
||||
dnfRateTrend: 0,
|
||||
};
|
||||
await this.leagueRepository.updateRatingMetrics(leagueId, defaultRatingMetrics);
|
||||
|
||||
// Initialize trend metrics
|
||||
const defaultTrendMetrics = {
|
||||
leagueId,
|
||||
incidentRateTrend: 0,
|
||||
penaltyRateTrend: 0,
|
||||
protestRateTrend: 0,
|
||||
stewardingActionRateTrend: 0,
|
||||
stewardingTimeTrend: 0,
|
||||
protestResolutionTimeTrend: 0,
|
||||
};
|
||||
await this.leagueRepository.updateTrendMetrics(leagueId, defaultTrendMetrics);
|
||||
|
||||
// Initialize success rate metrics
|
||||
const defaultSuccessRateMetrics = {
|
||||
leagueId,
|
||||
penaltyAppealSuccessRate: 0,
|
||||
protestSuccessRate: 0,
|
||||
stewardingActionSuccessRate: 0,
|
||||
stewardingActionAppealSuccessRate: 0,
|
||||
stewardingActionPenaltySuccessRate: 0,
|
||||
stewardingActionProtestSuccessRate: 0,
|
||||
};
|
||||
await this.leagueRepository.updateSuccessRateMetrics(leagueId, defaultSuccessRateMetrics);
|
||||
|
||||
// Initialize resolution time metrics
|
||||
const defaultResolutionTimeMetrics = {
|
||||
leagueId,
|
||||
averageStewardingTime: 0,
|
||||
averageProtestResolutionTime: 0,
|
||||
averageStewardingActionAppealPenaltyProtestResolutionTime: 0,
|
||||
};
|
||||
await this.leagueRepository.updateResolutionTimeMetrics(leagueId, defaultResolutionTimeMetrics);
|
||||
|
||||
// Initialize complex success rate metrics
|
||||
const defaultComplexSuccessRateMetrics = {
|
||||
leagueId,
|
||||
stewardingActionAppealPenaltyProtestSuccessRate: 0,
|
||||
stewardingActionAppealProtestSuccessRate: 0,
|
||||
stewardingActionPenaltyProtestSuccessRate: 0,
|
||||
stewardingActionAppealPenaltyProtestSuccessRate2: 0,
|
||||
};
|
||||
await this.leagueRepository.updateComplexSuccessRateMetrics(leagueId, defaultComplexSuccessRateMetrics);
|
||||
|
||||
// Initialize complex resolution time metrics
|
||||
const defaultComplexResolutionTimeMetrics = {
|
||||
leagueId,
|
||||
stewardingActionAppealPenaltyProtestResolutionTime: 0,
|
||||
stewardingActionAppealProtestResolutionTime: 0,
|
||||
stewardingActionPenaltyProtestResolutionTime: 0,
|
||||
stewardingActionAppealPenaltyProtestResolutionTime2: 0,
|
||||
};
|
||||
await this.leagueRepository.updateComplexResolutionTimeMetrics(leagueId, defaultComplexResolutionTimeMetrics);
|
||||
|
||||
// Emit event
|
||||
const event: LeagueCreatedEvent = {
|
||||
type: 'LeagueCreatedEvent',
|
||||
leagueId,
|
||||
ownerId: command.ownerId,
|
||||
timestamp: now,
|
||||
};
|
||||
await this.eventPublisher.emitLeagueCreated(event);
|
||||
|
||||
return savedLeague;
|
||||
}
|
||||
}
|
||||
40
core/leagues/application/use-cases/GetLeagueUseCase.ts
Normal file
40
core/leagues/application/use-cases/GetLeagueUseCase.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
|
||||
import { LeagueEventPublisher, LeagueAccessedEvent } from '../ports/LeagueEventPublisher';
|
||||
|
||||
export interface GetLeagueQuery {
|
||||
leagueId: string;
|
||||
driverId?: string;
|
||||
}
|
||||
|
||||
export class GetLeagueUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: LeagueRepository,
|
||||
private readonly eventPublisher: LeagueEventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(query: GetLeagueQuery): Promise<LeagueData> {
|
||||
// Validate query
|
||||
if (!query.leagueId || query.leagueId.trim() === '') {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
// Find league
|
||||
const league = await this.leagueRepository.findById(query.leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League with id ${query.leagueId} not found`);
|
||||
}
|
||||
|
||||
// Emit event if driver ID is provided
|
||||
if (query.driverId) {
|
||||
const event: LeagueAccessedEvent = {
|
||||
type: 'LeagueAccessedEvent',
|
||||
leagueId: query.leagueId,
|
||||
driverId: query.driverId,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
await this.eventPublisher.emitLeagueAccessed(event);
|
||||
}
|
||||
|
||||
return league;
|
||||
}
|
||||
}
|
||||
27
core/leagues/application/use-cases/SearchLeaguesUseCase.ts
Normal file
27
core/leagues/application/use-cases/SearchLeaguesUseCase.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
|
||||
|
||||
export interface SearchLeaguesQuery {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class SearchLeaguesUseCase {
|
||||
constructor(private readonly leagueRepository: LeagueRepository) {}
|
||||
|
||||
async execute(query: SearchLeaguesQuery): Promise<LeagueData[]> {
|
||||
// Validate query
|
||||
if (!query.query || query.query.trim() === '') {
|
||||
throw new Error('Search query is required');
|
||||
}
|
||||
|
||||
// Search leagues
|
||||
const results = await this.leagueRepository.search(query.query);
|
||||
|
||||
// Apply limit and offset
|
||||
const limit = query.limit || 10;
|
||||
const offset = query.offset || 0;
|
||||
|
||||
return results.slice(offset, offset + limit);
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,9 @@ export class DriverStatsUseCase {
|
||||
this._logger.debug(`Getting stats for driver ${driverId}`);
|
||||
return this._driverStatsRepository.getDriverStats(driverId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._logger.info('[DriverStatsUseCase] Clearing all stats');
|
||||
// No data to clear as this use case generates data on-the-fly
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,9 @@ export class RankingUseCase {
|
||||
|
||||
return rankings;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._logger.info('[RankingUseCase] Clearing all rankings');
|
||||
// No data to clear as this use case generates data on-the-fly
|
||||
}
|
||||
}
|
||||
16
core/shared/errors/ValidationError.ts
Normal file
16
core/shared/errors/ValidationError.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Validation Error
|
||||
*
|
||||
* Thrown when input validation fails.
|
||||
*/
|
||||
|
||||
export class ValidationError extends Error {
|
||||
readonly type = 'domain';
|
||||
readonly context = 'validation';
|
||||
readonly kind = 'validation';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user