integration tests
This commit is contained in:
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"');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user