178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
|
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
|
|
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
|
|
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
|
|
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
// Define types
|
|
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
|
|
type ImportRaceResultsSummaryDto = {
|
|
success: boolean;
|
|
raceId: string;
|
|
driversProcessed: number;
|
|
resultsRecorded: number;
|
|
errors?: string[];
|
|
};
|
|
|
|
export interface ImportResultRowDTO {
|
|
id: string;
|
|
raceId: string;
|
|
driverId: string;
|
|
position: number;
|
|
fastestLap: number;
|
|
incidents: number;
|
|
startPosition: number;
|
|
}
|
|
|
|
export interface CSVRow {
|
|
driverId: string;
|
|
position: number;
|
|
fastestLap: number;
|
|
incidents: number;
|
|
startPosition: number;
|
|
}
|
|
|
|
/**
|
|
* Race Results Service
|
|
*
|
|
* Orchestrates race results operations including viewing, importing, and SOF calculations.
|
|
* All dependencies are injected via constructor.
|
|
*/
|
|
export class RaceResultsService {
|
|
constructor(
|
|
private readonly apiClient: RacesApiClient
|
|
) {}
|
|
|
|
/**
|
|
* Get race results detail with view model transformation
|
|
*/
|
|
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
|
const dto = await this.apiClient.getResultsDetail(raceId);
|
|
return new RaceResultsDetailViewModel(dto, currentUserId || '');
|
|
}
|
|
|
|
/**
|
|
* Get race with strength of field calculation
|
|
*/
|
|
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
|
|
const dto = await this.apiClient.getWithSOF(raceId);
|
|
return new RaceWithSOFViewModel(dto);
|
|
}
|
|
|
|
/**
|
|
* Import race results and get summary
|
|
*/
|
|
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
|
|
const dto = await this.apiClient.importResults(raceId, input);
|
|
return new ImportRaceResultsSummaryViewModel(dto);
|
|
}
|
|
|
|
/**
|
|
* Parse CSV content and validate results
|
|
* @throws Error with descriptive message if validation fails
|
|
*/
|
|
parseCSV(content: string): CSVRow[] {
|
|
const lines = content.trim().split('\n');
|
|
|
|
if (lines.length < 2) {
|
|
throw new Error('CSV file is empty or invalid');
|
|
}
|
|
|
|
const headerLine = lines[0]!;
|
|
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
|
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
|
|
|
for (const field of requiredFields) {
|
|
if (!header.includes(field)) {
|
|
throw new Error(`Missing required field: ${field}`);
|
|
}
|
|
}
|
|
|
|
const rows: CSVRow[] = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
const values = line.split(',').map((v) => v.trim());
|
|
|
|
if (values.length !== header.length) {
|
|
throw new Error(
|
|
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
|
);
|
|
}
|
|
|
|
const row: Record<string, string> = {};
|
|
header.forEach((field, index) => {
|
|
row[field] = values[index] ?? '';
|
|
});
|
|
|
|
const driverId = row['driverid'] ?? '';
|
|
const position = parseInt(row['position'] ?? '', 10);
|
|
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
|
const incidents = parseInt(row['incidents'] ?? '', 10);
|
|
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
|
|
|
if (!driverId || driverId.length === 0) {
|
|
throw new Error(`Row ${i}: driverId is required`);
|
|
}
|
|
|
|
if (Number.isNaN(position) || position < 1) {
|
|
throw new Error(`Row ${i}: position must be a positive integer`);
|
|
}
|
|
|
|
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
|
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
|
}
|
|
|
|
if (Number.isNaN(incidents) || incidents < 0) {
|
|
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
|
}
|
|
|
|
if (Number.isNaN(startPosition) || startPosition < 1) {
|
|
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
|
}
|
|
|
|
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
|
}
|
|
|
|
const positions = rows.map((r) => r.position);
|
|
const uniquePositions = new Set(positions);
|
|
if (positions.length !== uniquePositions.size) {
|
|
throw new Error('Duplicate positions found in CSV');
|
|
}
|
|
|
|
const driverIds = rows.map((r) => r.driverId);
|
|
const uniqueDrivers = new Set(driverIds);
|
|
if (driverIds.length !== uniqueDrivers.size) {
|
|
throw new Error('Duplicate driver IDs found in CSV');
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Transform parsed CSV rows into ImportResultRowDTO array
|
|
*/
|
|
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
|
|
return rows.map((row) => ({
|
|
id: uuidv4(),
|
|
raceId,
|
|
driverId: row.driverId,
|
|
position: row.position,
|
|
fastestLap: row.fastestLap,
|
|
incidents: row.incidents,
|
|
startPosition: row.startPosition,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Parse CSV file content and transform to import results
|
|
* @throws Error with descriptive message if parsing or validation fails
|
|
*/
|
|
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
|
|
const rows = this.parseCSV(content);
|
|
return this.transformToImportResults(rows, raceId);
|
|
}
|
|
} |