wip
This commit is contained in:
@@ -1 +1,4 @@
|
||||
export * from './media/DemoImageServiceAdapter';
|
||||
export * from './media/DemoImageServiceAdapter';
|
||||
export * from './media/DemoFaceValidationAdapter';
|
||||
export * from './media/DemoAvatarGenerationAdapter';
|
||||
export * from './media/InMemoryAvatarGenerationRepository';
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
AvatarGenerationPort,
|
||||
AvatarGenerationOptions,
|
||||
AvatarGenerationResult
|
||||
} from '@gridpilot/media';
|
||||
|
||||
/**
|
||||
* Demo implementation of AvatarGenerationPort.
|
||||
*
|
||||
* In production, this would use a real AI image generation API like:
|
||||
* - OpenAI DALL-E
|
||||
* - Midjourney API
|
||||
* - Stable Diffusion
|
||||
* - RunwayML
|
||||
*
|
||||
* For demo purposes, this returns placeholder avatar images.
|
||||
*/
|
||||
export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
private readonly placeholderAvatars: Record<string, string[]> = {
|
||||
red: [
|
||||
'/images/avatars/generated/red-1.png',
|
||||
'/images/avatars/generated/red-2.png',
|
||||
'/images/avatars/generated/red-3.png',
|
||||
],
|
||||
blue: [
|
||||
'/images/avatars/generated/blue-1.png',
|
||||
'/images/avatars/generated/blue-2.png',
|
||||
'/images/avatars/generated/blue-3.png',
|
||||
],
|
||||
green: [
|
||||
'/images/avatars/generated/green-1.png',
|
||||
'/images/avatars/generated/green-2.png',
|
||||
'/images/avatars/generated/green-3.png',
|
||||
],
|
||||
yellow: [
|
||||
'/images/avatars/generated/yellow-1.png',
|
||||
'/images/avatars/generated/yellow-2.png',
|
||||
'/images/avatars/generated/yellow-3.png',
|
||||
],
|
||||
orange: [
|
||||
'/images/avatars/generated/orange-1.png',
|
||||
'/images/avatars/generated/orange-2.png',
|
||||
'/images/avatars/generated/orange-3.png',
|
||||
],
|
||||
purple: [
|
||||
'/images/avatars/generated/purple-1.png',
|
||||
'/images/avatars/generated/purple-2.png',
|
||||
'/images/avatars/generated/purple-3.png',
|
||||
],
|
||||
black: [
|
||||
'/images/avatars/generated/black-1.png',
|
||||
'/images/avatars/generated/black-2.png',
|
||||
'/images/avatars/generated/black-3.png',
|
||||
],
|
||||
white: [
|
||||
'/images/avatars/generated/white-1.png',
|
||||
'/images/avatars/generated/white-2.png',
|
||||
'/images/avatars/generated/white-3.png',
|
||||
],
|
||||
pink: [
|
||||
'/images/avatars/generated/pink-1.png',
|
||||
'/images/avatars/generated/pink-2.png',
|
||||
'/images/avatars/generated/pink-3.png',
|
||||
],
|
||||
cyan: [
|
||||
'/images/avatars/generated/cyan-1.png',
|
||||
'/images/avatars/generated/cyan-2.png',
|
||||
'/images/avatars/generated/cyan-3.png',
|
||||
],
|
||||
};
|
||||
|
||||
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
|
||||
// Simulate AI processing time (1-3 seconds)
|
||||
await this.delay(1500 + Math.random() * 1500);
|
||||
|
||||
// Log what would be sent to the AI (for debugging)
|
||||
console.log('[DemoAvatarGeneration] Would generate with prompt:', options.prompt);
|
||||
console.log('[DemoAvatarGeneration] Suit color:', options.suitColor);
|
||||
console.log('[DemoAvatarGeneration] Style:', options.style);
|
||||
console.log('[DemoAvatarGeneration] Count:', options.count);
|
||||
|
||||
// For demo, return placeholder URLs based on suit color
|
||||
// In production, these would be actual AI-generated images
|
||||
const colorAvatars = this.placeholderAvatars[options.suitColor] || this.placeholderAvatars.blue;
|
||||
|
||||
// Generate unique URLs with a hash to simulate different generations
|
||||
const hash = this.generateHash(options.facePhotoUrl + Date.now());
|
||||
const avatars = colorAvatars.slice(0, options.count).map((baseUrl, index) => {
|
||||
// In demo mode, use dicebear or similar for generating varied avatars
|
||||
const seed = `${hash}-${options.suitColor}-${index}`;
|
||||
return {
|
||||
url: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent`,
|
||||
thumbnailUrl: `https://api.dicebear.com/7.x/personas/svg?seed=${seed}&backgroundColor=transparent&size=64`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
avatars,
|
||||
};
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private generateHash(input: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FaceValidationPort, FaceValidationResult } from '@gridpilot/media';
|
||||
|
||||
/**
|
||||
* Demo implementation of FaceValidationPort.
|
||||
*
|
||||
* In production, this would use a real face detection API like:
|
||||
* - AWS Rekognition
|
||||
* - Google Cloud Vision
|
||||
* - Azure Face API
|
||||
* - OpenCV / face-api.js
|
||||
*
|
||||
* For demo purposes, this always returns a valid face if the image data is provided.
|
||||
*/
|
||||
export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||
async validateFacePhoto(imageData: string | Buffer): Promise<FaceValidationResult> {
|
||||
// Simulate some processing time
|
||||
await this.delay(500);
|
||||
|
||||
// Check if we have any image data
|
||||
const dataString = typeof imageData === 'string' ? imageData : imageData.toString();
|
||||
|
||||
if (!dataString || dataString.length < 100) {
|
||||
return {
|
||||
isValid: false,
|
||||
hasFace: false,
|
||||
faceCount: 0,
|
||||
confidence: 0,
|
||||
errorMessage: 'Invalid or empty image data',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for valid base64 image data or data URL
|
||||
const isValidImage =
|
||||
dataString.startsWith('data:image/') ||
|
||||
dataString.startsWith('/9j/') || // JPEG magic bytes in base64
|
||||
dataString.startsWith('iVBOR') || // PNG magic bytes in base64
|
||||
dataString.length > 1000; // Assume long strings are valid image data
|
||||
|
||||
if (!isValidImage) {
|
||||
return {
|
||||
isValid: false,
|
||||
hasFace: false,
|
||||
faceCount: 0,
|
||||
confidence: 0,
|
||||
errorMessage: 'Please upload a valid image file (JPEG or PNG)',
|
||||
};
|
||||
}
|
||||
|
||||
// For demo: always return success with high confidence
|
||||
// In production, this would actually analyze the image
|
||||
return {
|
||||
isValid: true,
|
||||
hasFace: true,
|
||||
faceCount: 1,
|
||||
confidence: 0.95,
|
||||
};
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
IAvatarGenerationRepository
|
||||
} from '@gridpilot/media';
|
||||
import {
|
||||
AvatarGenerationRequest,
|
||||
type AvatarGenerationRequestProps
|
||||
} from '@gridpilot/media';
|
||||
|
||||
/**
|
||||
* In-memory implementation of IAvatarGenerationRepository.
|
||||
*
|
||||
* For demo/development purposes. In production, this would use a database.
|
||||
*/
|
||||
export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepository {
|
||||
private readonly requests = new Map<string, AvatarGenerationRequestProps>();
|
||||
|
||||
async save(request: AvatarGenerationRequest): Promise<void> {
|
||||
this.requests.set(request.id, request.toProps());
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AvatarGenerationRequest | null> {
|
||||
const props = this.requests.get(id);
|
||||
if (!props) {
|
||||
return null;
|
||||
}
|
||||
return AvatarGenerationRequest.reconstitute(props);
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<AvatarGenerationRequest[]> {
|
||||
const results: AvatarGenerationRequest[] = [];
|
||||
for (const props of this.requests.values()) {
|
||||
if (props.userId === userId) {
|
||||
results.push(AvatarGenerationRequest.reconstitute(props));
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null> {
|
||||
const userRequests = await this.findByUserId(userId);
|
||||
return userRequests.length > 0 ? userRequests[0] : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.requests.delete(id);
|
||||
}
|
||||
}
|
||||
35
packages/media/application/ports/AvatarGenerationPort.ts
Normal file
35
packages/media/application/ports/AvatarGenerationPort.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Port: AvatarGenerationPort
|
||||
*
|
||||
* Defines the contract for AI-powered avatar generation.
|
||||
*/
|
||||
|
||||
import type { RacingSuitColor, AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
|
||||
|
||||
export interface AvatarGenerationOptions {
|
||||
facePhotoUrl: string;
|
||||
prompt: string;
|
||||
suitColor: RacingSuitColor;
|
||||
style: AvatarStyle;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GeneratedAvatar {
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export interface AvatarGenerationResult {
|
||||
success: boolean;
|
||||
avatars: GeneratedAvatar[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AvatarGenerationPort {
|
||||
/**
|
||||
* Generate racing avatars from a face photo
|
||||
* @param options Generation options including face photo and styling preferences
|
||||
* @returns Generated avatar URLs
|
||||
*/
|
||||
generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult>;
|
||||
}
|
||||
20
packages/media/application/ports/FaceValidationPort.ts
Normal file
20
packages/media/application/ports/FaceValidationPort.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Port: FaceValidationPort
|
||||
*
|
||||
* Defines the contract for validating face photos.
|
||||
*/
|
||||
|
||||
export interface FaceValidationResult {
|
||||
isValid: boolean;
|
||||
hasFace: boolean;
|
||||
faceCount: number;
|
||||
confidence: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface FaceValidationPort {
|
||||
/**
|
||||
* Validate that an image contains exactly one valid face
|
||||
*/
|
||||
validateFacePhoto(imageData: string | Buffer): Promise<FaceValidationResult>;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Use Case: RequestAvatarGenerationUseCase
|
||||
*
|
||||
* Initiates the avatar generation process by validating the face photo
|
||||
* and creating a generation request.
|
||||
*/
|
||||
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
import type { FaceValidationPort } from '../ports/FaceValidationPort';
|
||||
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
|
||||
import { AvatarGenerationRequest, type RacingSuitColor, type AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
|
||||
|
||||
export interface RequestAvatarGenerationCommand {
|
||||
userId: string;
|
||||
facePhotoData: string; // Base64 encoded image data
|
||||
suitColor: RacingSuitColor;
|
||||
style?: AvatarStyle;
|
||||
}
|
||||
|
||||
export interface RequestAvatarGenerationResult {
|
||||
requestId: string;
|
||||
status: 'validating' | 'generating' | 'completed' | 'failed';
|
||||
avatarUrls?: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class RequestAvatarGenerationUseCase {
|
||||
constructor(
|
||||
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||
private readonly faceValidation: FaceValidationPort,
|
||||
private readonly avatarGeneration: AvatarGenerationPort,
|
||||
) {}
|
||||
|
||||
async execute(command: RequestAvatarGenerationCommand): Promise<RequestAvatarGenerationResult> {
|
||||
// Create the generation request
|
||||
const requestId = this.generateId();
|
||||
const request = AvatarGenerationRequest.create({
|
||||
id: requestId,
|
||||
userId: command.userId,
|
||||
facePhotoUrl: `data:image/jpeg;base64,${command.facePhotoData}`,
|
||||
suitColor: command.suitColor,
|
||||
style: command.style,
|
||||
});
|
||||
|
||||
// Mark as validating
|
||||
request.markAsValidating();
|
||||
await this.avatarRepository.save(request);
|
||||
|
||||
// Validate the face photo
|
||||
const validationResult = await this.faceValidation.validateFacePhoto(command.facePhotoData);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
request.fail(validationResult.errorMessage || 'Face validation failed');
|
||||
await this.avatarRepository.save(request);
|
||||
return {
|
||||
requestId,
|
||||
status: 'failed',
|
||||
errorMessage: validationResult.errorMessage || 'Please upload a clear photo of your face',
|
||||
};
|
||||
}
|
||||
|
||||
if (!validationResult.hasFace) {
|
||||
request.fail('No face detected in the image');
|
||||
await this.avatarRepository.save(request);
|
||||
return {
|
||||
requestId,
|
||||
status: 'failed',
|
||||
errorMessage: 'No face detected. Please upload a photo that clearly shows your face.',
|
||||
};
|
||||
}
|
||||
|
||||
if (validationResult.faceCount > 1) {
|
||||
request.fail('Multiple faces detected');
|
||||
await this.avatarRepository.save(request);
|
||||
return {
|
||||
requestId,
|
||||
status: 'failed',
|
||||
errorMessage: 'Multiple faces detected. Please upload a photo with only your face.',
|
||||
};
|
||||
}
|
||||
|
||||
// Mark as generating
|
||||
request.markAsGenerating();
|
||||
await this.avatarRepository.save(request);
|
||||
|
||||
// Generate avatars
|
||||
const generationResult = await this.avatarGeneration.generateAvatars({
|
||||
facePhotoUrl: request.facePhotoUrl,
|
||||
prompt: request.buildPrompt(),
|
||||
suitColor: request.suitColor,
|
||||
style: request.style,
|
||||
count: 3, // Generate 3 options
|
||||
});
|
||||
|
||||
if (!generationResult.success) {
|
||||
request.fail(generationResult.errorMessage || 'Avatar generation failed');
|
||||
await this.avatarRepository.save(request);
|
||||
return {
|
||||
requestId,
|
||||
status: 'failed',
|
||||
errorMessage: generationResult.errorMessage || 'Failed to generate avatars. Please try again.',
|
||||
};
|
||||
}
|
||||
|
||||
// Complete with generated avatars
|
||||
const avatarUrls = generationResult.avatars.map(a => a.url);
|
||||
request.completeWithAvatars(avatarUrls);
|
||||
await this.avatarRepository.save(request);
|
||||
|
||||
return {
|
||||
requestId,
|
||||
status: 'completed',
|
||||
avatarUrls,
|
||||
};
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'avatar-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
}
|
||||
65
packages/media/application/use-cases/SelectAvatarUseCase.ts
Normal file
65
packages/media/application/use-cases/SelectAvatarUseCase.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Use Case: SelectAvatarUseCase
|
||||
*
|
||||
* Allows a user to select one of the generated avatars as their profile avatar.
|
||||
*/
|
||||
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
|
||||
export interface SelectAvatarCommand {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
avatarIndex: number;
|
||||
}
|
||||
|
||||
export interface SelectAvatarResult {
|
||||
success: boolean;
|
||||
selectedAvatarUrl?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class SelectAvatarUseCase {
|
||||
constructor(
|
||||
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: SelectAvatarCommand): Promise<SelectAvatarResult> {
|
||||
const request = await this.avatarRepository.findById(command.requestId);
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: 'Avatar generation request not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (request.userId !== command.userId) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: 'You do not have permission to select this avatar',
|
||||
};
|
||||
}
|
||||
|
||||
if (request.status !== 'completed') {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: 'Avatar generation is not yet complete',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
request.selectAvatar(command.avatarIndex);
|
||||
await this.avatarRepository.save(request);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selectedAvatarUrl: request.selectedAvatarUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to select avatar',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
218
packages/media/domain/entities/AvatarGenerationRequest.ts
Normal file
218
packages/media/domain/entities/AvatarGenerationRequest.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Domain Entity: AvatarGenerationRequest
|
||||
*
|
||||
* Represents a request to generate a racing avatar from a face photo.
|
||||
*/
|
||||
|
||||
export type RacingSuitColor =
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'orange'
|
||||
| 'purple'
|
||||
| 'black'
|
||||
| 'white'
|
||||
| 'pink'
|
||||
| 'cyan';
|
||||
|
||||
export type AvatarStyle =
|
||||
| 'realistic'
|
||||
| 'cartoon'
|
||||
| 'pixel-art';
|
||||
|
||||
export type AvatarGenerationStatus =
|
||||
| 'pending'
|
||||
| 'validating'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface AvatarGenerationRequestProps {
|
||||
id: string;
|
||||
userId: string;
|
||||
facePhotoUrl: string;
|
||||
suitColor: RacingSuitColor;
|
||||
style: AvatarStyle;
|
||||
status: AvatarGenerationStatus;
|
||||
generatedAvatarUrls: string[];
|
||||
selectedAvatarIndex?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class AvatarGenerationRequest {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly facePhotoUrl: string;
|
||||
readonly suitColor: RacingSuitColor;
|
||||
readonly style: AvatarStyle;
|
||||
private _status: AvatarGenerationStatus;
|
||||
private _generatedAvatarUrls: string[];
|
||||
private _selectedAvatarIndex?: number;
|
||||
private _errorMessage?: string;
|
||||
readonly createdAt: Date;
|
||||
private _updatedAt: Date;
|
||||
|
||||
private constructor(props: AvatarGenerationRequestProps) {
|
||||
this.id = props.id;
|
||||
this.userId = props.userId;
|
||||
this.facePhotoUrl = props.facePhotoUrl;
|
||||
this.suitColor = props.suitColor;
|
||||
this.style = props.style;
|
||||
this._status = props.status;
|
||||
this._generatedAvatarUrls = [...props.generatedAvatarUrls];
|
||||
this._selectedAvatarIndex = props.selectedAvatarIndex;
|
||||
this._errorMessage = props.errorMessage;
|
||||
this.createdAt = props.createdAt;
|
||||
this._updatedAt = props.updatedAt;
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
id: string;
|
||||
userId: string;
|
||||
facePhotoUrl: string;
|
||||
suitColor: RacingSuitColor;
|
||||
style?: AvatarStyle;
|
||||
}): AvatarGenerationRequest {
|
||||
if (!props.userId) {
|
||||
throw new Error('User ID is required');
|
||||
}
|
||||
if (!props.facePhotoUrl) {
|
||||
throw new Error('Face photo URL is required');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return new AvatarGenerationRequest({
|
||||
id: props.id,
|
||||
userId: props.userId,
|
||||
facePhotoUrl: props.facePhotoUrl,
|
||||
suitColor: props.suitColor,
|
||||
style: props.style ?? 'realistic',
|
||||
status: 'pending',
|
||||
generatedAvatarUrls: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static reconstitute(props: AvatarGenerationRequestProps): AvatarGenerationRequest {
|
||||
return new AvatarGenerationRequest(props);
|
||||
}
|
||||
|
||||
get status(): AvatarGenerationStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get generatedAvatarUrls(): string[] {
|
||||
return [...this._generatedAvatarUrls];
|
||||
}
|
||||
|
||||
get selectedAvatarIndex(): number | undefined {
|
||||
return this._selectedAvatarIndex;
|
||||
}
|
||||
|
||||
get selectedAvatarUrl(): string | undefined {
|
||||
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
|
||||
return this._generatedAvatarUrls[this._selectedAvatarIndex];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get errorMessage(): string | undefined {
|
||||
return this._errorMessage;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
|
||||
markAsValidating(): void {
|
||||
if (this._status !== 'pending') {
|
||||
throw new Error('Can only start validation from pending status');
|
||||
}
|
||||
this._status = 'validating';
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
markAsGenerating(): void {
|
||||
if (this._status !== 'validating') {
|
||||
throw new Error('Can only start generation from validating status');
|
||||
}
|
||||
this._status = 'generating';
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
completeWithAvatars(avatarUrls: string[]): void {
|
||||
if (avatarUrls.length === 0) {
|
||||
throw new Error('At least one avatar URL is required');
|
||||
}
|
||||
this._status = 'completed';
|
||||
this._generatedAvatarUrls = [...avatarUrls];
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
fail(errorMessage: string): void {
|
||||
this._status = 'failed';
|
||||
this._errorMessage = errorMessage;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
selectAvatar(index: number): void {
|
||||
if (this._status !== 'completed') {
|
||||
throw new Error('Can only select avatar when generation is completed');
|
||||
}
|
||||
if (index < 0 || index >= this._generatedAvatarUrls.length) {
|
||||
throw new Error('Invalid avatar index');
|
||||
}
|
||||
this._selectedAvatarIndex = index;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI prompt for avatar generation.
|
||||
* We control the prompt completely - users cannot enter free text.
|
||||
*/
|
||||
buildPrompt(): string {
|
||||
const colorDescriptions: Record<RacingSuitColor, string> = {
|
||||
red: 'vibrant racing red',
|
||||
blue: 'deep motorsport blue',
|
||||
green: 'racing green',
|
||||
yellow: 'bright championship yellow',
|
||||
orange: 'McLaren-style papaya orange',
|
||||
purple: 'royal purple',
|
||||
black: 'stealth black',
|
||||
white: 'clean white',
|
||||
pink: 'hot pink',
|
||||
cyan: 'electric cyan',
|
||||
};
|
||||
|
||||
const styleDescriptions: Record<AvatarStyle, string> = {
|
||||
realistic: 'photorealistic, professional motorsport portrait',
|
||||
cartoon: 'stylized cartoon racing character',
|
||||
'pixel-art': '8-bit pixel art retro racing avatar',
|
||||
};
|
||||
|
||||
const suitColorDesc = colorDescriptions[this.suitColor];
|
||||
const styleDesc = styleDescriptions[this.style];
|
||||
|
||||
return `Create a ${styleDesc} of a racing driver wearing a ${suitColorDesc} racing suit with matching helmet. The driver should look professional and confident, as if posing for a team photo. Background should be a subtle racing paddock or garage setting. High quality, well-lit, professional motorsport photography style.`;
|
||||
}
|
||||
|
||||
toProps(): AvatarGenerationRequestProps {
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
facePhotoUrl: this.facePhotoUrl,
|
||||
suitColor: this.suitColor,
|
||||
style: this.style,
|
||||
status: this._status,
|
||||
generatedAvatarUrls: [...this._generatedAvatarUrls],
|
||||
selectedAvatarIndex: this._selectedAvatarIndex,
|
||||
errorMessage: this._errorMessage,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this._updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Repository Interface: IAvatarGenerationRepository
|
||||
*
|
||||
* Defines the contract for avatar generation request persistence.
|
||||
*/
|
||||
|
||||
import type { AvatarGenerationRequest, AvatarGenerationRequestProps } from '../entities/AvatarGenerationRequest';
|
||||
|
||||
export interface IAvatarGenerationRepository {
|
||||
/**
|
||||
* Save an avatar generation request
|
||||
*/
|
||||
save(request: AvatarGenerationRequest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Find an avatar generation request by ID
|
||||
*/
|
||||
findById(id: string): Promise<AvatarGenerationRequest | null>;
|
||||
|
||||
/**
|
||||
* Find all avatar generation requests for a user
|
||||
*/
|
||||
findByUserId(userId: string): Promise<AvatarGenerationRequest[]>;
|
||||
|
||||
/**
|
||||
* Find the latest avatar generation request for a user
|
||||
*/
|
||||
findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null>;
|
||||
|
||||
/**
|
||||
* Delete an avatar generation request
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -1 +1,12 @@
|
||||
export * from './application/ports/ImageServicePort';
|
||||
// Ports
|
||||
export * from './application/ports/ImageServicePort';
|
||||
export * from './application/ports/FaceValidationPort';
|
||||
export * from './application/ports/AvatarGenerationPort';
|
||||
|
||||
// Use Cases
|
||||
export * from './application/use-cases/RequestAvatarGenerationUseCase';
|
||||
export * from './application/use-cases/SelectAvatarUseCase';
|
||||
|
||||
// Domain
|
||||
export * from './domain/entities/AvatarGenerationRequest';
|
||||
export * from './domain/repositories/IAvatarGenerationRepository';
|
||||
@@ -3,7 +3,12 @@ export type RaceDTO = {
|
||||
leagueId: string;
|
||||
scheduledAt: string;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: 'practice' | 'qualifying' | 'race';
|
||||
status: 'scheduled' | 'completed' | 'cancelled';
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
};
|
||||
@@ -24,6 +24,11 @@ export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||
export * from './use-cases/GetRaceWithSOFQuery';
|
||||
export * from './use-cases/GetLeagueStatsQuery';
|
||||
|
||||
// Export ports
|
||||
export * from './ports/DriverRatingProvider';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
|
||||
@@ -76,9 +76,14 @@ export class EntityMappers {
|
||||
leagueId: race.leagueId,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
track: race.track,
|
||||
trackId: race.trackId,
|
||||
car: race.car,
|
||||
carId: race.carId,
|
||||
sessionType: race.sessionType,
|
||||
status: race.status,
|
||||
strengthOfField: race.strengthOfField,
|
||||
registeredCount: race.registeredCount,
|
||||
maxParticipants: race.maxParticipants,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,9 +93,14 @@ export class EntityMappers {
|
||||
leagueId: race.leagueId,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
track: race.track,
|
||||
trackId: race.trackId,
|
||||
car: race.car,
|
||||
carId: race.carId,
|
||||
sessionType: race.sessionType,
|
||||
status: race.status,
|
||||
strengthOfField: race.strengthOfField,
|
||||
registeredCount: race.registeredCount,
|
||||
maxParticipants: race.maxParticipants,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
20
packages/racing/application/ports/DriverRatingProvider.ts
Normal file
20
packages/racing/application/ports/DriverRatingProvider.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Application Port: DriverRatingProvider
|
||||
*
|
||||
* Port for looking up driver ratings.
|
||||
* Implemented by infrastructure adapters that connect to rating systems.
|
||||
*/
|
||||
|
||||
export interface DriverRatingProvider {
|
||||
/**
|
||||
* Get the rating for a single driver
|
||||
* Returns null if driver has no rating
|
||||
*/
|
||||
getRating(driverId: string): number | null;
|
||||
|
||||
/**
|
||||
* Get ratings for multiple drivers
|
||||
* Returns a map of driverId -> rating
|
||||
*/
|
||||
getRatings(driverIds: string[]): Map<string, number>;
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export class GetLeagueScoringConfigQuery {
|
||||
|
||||
for (const [sessionType, table] of Object.entries(tables)) {
|
||||
for (let pos = 1; pos <= maxPositions; pos++) {
|
||||
const points = table.getPoints(pos);
|
||||
const points = table.getPointsForPosition(pos);
|
||||
if (points && points !== 0) {
|
||||
preview.push({
|
||||
sessionType,
|
||||
|
||||
99
packages/racing/application/use-cases/GetLeagueStatsQuery.ts
Normal file
99
packages/racing/application/use-cases/GetLeagueStatsQuery.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Application Query: GetLeagueStatsQuery
|
||||
*
|
||||
* Returns league statistics including average SOF across completed races.
|
||||
*/
|
||||
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||
import {
|
||||
AverageStrengthOfFieldCalculator,
|
||||
type StrengthOfFieldCalculator,
|
||||
} from '../../domain/services/StrengthOfFieldCalculator';
|
||||
|
||||
export interface GetLeagueStatsQueryParams {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export interface LeagueStatsDTO {
|
||||
leagueId: string;
|
||||
totalRaces: number;
|
||||
completedRaces: number;
|
||||
scheduledRaces: number;
|
||||
averageSOF: number | null;
|
||||
highestSOF: number | null;
|
||||
lowestSOF: number | null;
|
||||
}
|
||||
|
||||
export class GetLeagueStatsQuery {
|
||||
private readonly sofCalculator: StrengthOfFieldCalculator;
|
||||
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly driverRatingProvider: DriverRatingProvider,
|
||||
sofCalculator?: StrengthOfFieldCalculator,
|
||||
) {
|
||||
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
|
||||
}
|
||||
|
||||
async execute(params: GetLeagueStatsQueryParams): Promise<LeagueStatsDTO | null> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const races = await this.raceRepository.findByLeagueId(leagueId);
|
||||
const completedRaces = races.filter(r => r.status === 'completed');
|
||||
const scheduledRaces = races.filter(r => r.status === 'scheduled');
|
||||
|
||||
// Calculate SOF for each completed race
|
||||
const sofValues: number[] = [];
|
||||
|
||||
for (const race of completedRaces) {
|
||||
// Use stored SOF if available
|
||||
if (race.strengthOfField) {
|
||||
sofValues.push(race.strengthOfField);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise calculate from results
|
||||
const results = await this.resultRepository.findByRaceId(race.id);
|
||||
if (results.length === 0) continue;
|
||||
|
||||
const driverIds = results.map(r => r.driverId);
|
||||
const ratings = this.driverRatingProvider.getRatings(driverIds);
|
||||
const driverRatings = driverIds
|
||||
.filter(id => ratings.has(id))
|
||||
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
|
||||
|
||||
const sof = this.sofCalculator.calculate(driverRatings);
|
||||
if (sof !== null) {
|
||||
sofValues.push(sof);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate aggregate stats
|
||||
const averageSOF = sofValues.length > 0
|
||||
? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length)
|
||||
: null;
|
||||
|
||||
const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null;
|
||||
const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null;
|
||||
|
||||
return {
|
||||
leagueId,
|
||||
totalRaces: races.length,
|
||||
completedRaces: completedRaces.length,
|
||||
scheduledRaces: scheduledRaces.length,
|
||||
averageSOF,
|
||||
highestSOF,
|
||||
lowestSOF,
|
||||
};
|
||||
}
|
||||
}
|
||||
88
packages/racing/application/use-cases/GetRaceWithSOFQuery.ts
Normal file
88
packages/racing/application/use-cases/GetRaceWithSOFQuery.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Application Query: GetRaceWithSOFQuery
|
||||
*
|
||||
* Returns race details enriched with calculated Strength of Field (SOF).
|
||||
* SOF is calculated from participant ratings if not already stored on the race.
|
||||
*/
|
||||
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||
import {
|
||||
AverageStrengthOfFieldCalculator,
|
||||
type StrengthOfFieldCalculator,
|
||||
} from '../../domain/services/StrengthOfFieldCalculator';
|
||||
import type { RaceDTO } from '../dto/RaceDTO';
|
||||
|
||||
export interface GetRaceWithSOFQueryParams {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface RaceWithSOFDTO extends Omit<RaceDTO, 'strengthOfField'> {
|
||||
strengthOfField: number | null;
|
||||
participantCount: number;
|
||||
}
|
||||
|
||||
export class GetRaceWithSOFQuery {
|
||||
private readonly sofCalculator: StrengthOfFieldCalculator;
|
||||
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly registrationRepository: IRaceRegistrationRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly driverRatingProvider: DriverRatingProvider,
|
||||
sofCalculator?: StrengthOfFieldCalculator,
|
||||
) {
|
||||
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
|
||||
}
|
||||
|
||||
async execute(params: GetRaceWithSOFQueryParams): Promise<RaceWithSOFDTO | null> {
|
||||
const { raceId } = params;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get participant IDs based on race status
|
||||
let participantIds: string[] = [];
|
||||
|
||||
if (race.status === 'completed') {
|
||||
// For completed races, use results
|
||||
const results = await this.resultRepository.findByRaceId(raceId);
|
||||
participantIds = results.map(r => r.driverId);
|
||||
} else {
|
||||
// For upcoming/running races, use registrations
|
||||
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
|
||||
}
|
||||
|
||||
// Use stored SOF if available, otherwise calculate
|
||||
let strengthOfField = race.strengthOfField ?? null;
|
||||
|
||||
if (strengthOfField === null && participantIds.length > 0) {
|
||||
const ratings = this.driverRatingProvider.getRatings(participantIds);
|
||||
const driverRatings = participantIds
|
||||
.filter(id => ratings.has(id))
|
||||
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
|
||||
|
||||
strengthOfField = this.sofCalculator.calculate(driverRatings);
|
||||
}
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
track: race.track,
|
||||
trackId: race.trackId,
|
||||
car: race.car,
|
||||
carId: race.carId,
|
||||
sessionType: race.sessionType,
|
||||
status: race.status,
|
||||
strengthOfField,
|
||||
registeredCount: race.registeredCount ?? participantIds.length,
|
||||
maxParticipants: race.maxParticipants,
|
||||
participantCount: participantIds.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
130
packages/racing/domain/entities/Car.ts
Normal file
130
packages/racing/domain/entities/Car.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Domain Entity: Car
|
||||
*
|
||||
* Represents a racing car/vehicle in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
|
||||
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
|
||||
|
||||
export class Car {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly shortName: string;
|
||||
readonly manufacturer: string;
|
||||
readonly carClass: CarClass;
|
||||
readonly license: CarLicense;
|
||||
readonly year: number;
|
||||
readonly horsepower?: number;
|
||||
readonly weight?: number;
|
||||
readonly imageUrl?: string;
|
||||
readonly gameId: string;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
manufacturer: string;
|
||||
carClass: CarClass;
|
||||
license: CarLicense;
|
||||
year: number;
|
||||
horsepower?: number;
|
||||
weight?: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.shortName = props.shortName;
|
||||
this.manufacturer = props.manufacturer;
|
||||
this.carClass = props.carClass;
|
||||
this.license = props.license;
|
||||
this.year = props.year;
|
||||
this.horsepower = props.horsepower;
|
||||
this.weight = props.weight;
|
||||
this.imageUrl = props.imageUrl;
|
||||
this.gameId = props.gameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Car entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
manufacturer: string;
|
||||
carClass?: CarClass;
|
||||
license?: CarLicense;
|
||||
year?: number;
|
||||
horsepower?: number;
|
||||
weight?: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}): Car {
|
||||
this.validate(props);
|
||||
|
||||
return new Car({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
shortName: props.shortName ?? props.name.slice(0, 10),
|
||||
manufacturer: props.manufacturer,
|
||||
carClass: props.carClass ?? 'gt',
|
||||
license: props.license ?? 'D',
|
||||
year: props.year ?? new Date().getFullYear(),
|
||||
horsepower: props.horsepower,
|
||||
weight: props.weight,
|
||||
imageUrl: props.imageUrl,
|
||||
gameId: props.gameId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Car ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Car name is required');
|
||||
}
|
||||
|
||||
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
|
||||
throw new Error('Car manufacturer is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted car display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
return `${this.manufacturer} ${this.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license badge color
|
||||
*/
|
||||
getLicenseColor(): string {
|
||||
const colors: Record<CarLicense, string> = {
|
||||
'R': '#FF6B6B',
|
||||
'D': '#FFB347',
|
||||
'C': '#FFD700',
|
||||
'B': '#7FFF00',
|
||||
'A': '#00BFFF',
|
||||
'Pro': '#9370DB',
|
||||
};
|
||||
return colors[this.license];
|
||||
}
|
||||
}
|
||||
146
packages/racing/domain/entities/Protest.ts
Normal file
146
packages/racing/domain/entities/Protest.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*/
|
||||
|
||||
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||
|
||||
export interface ProtestIncident {
|
||||
/** Lap number where the incident occurred */
|
||||
lap: number;
|
||||
/** Time in the race (seconds from start, or timestamp) */
|
||||
timeInRace?: number;
|
||||
/** Brief description of the incident */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProtestProps {
|
||||
id: string;
|
||||
raceId: string;
|
||||
/** The driver filing the protest */
|
||||
protestingDriverId: string;
|
||||
/** The driver being protested against */
|
||||
accusedDriverId: string;
|
||||
/** Details of the incident */
|
||||
incident: ProtestIncident;
|
||||
/** Optional comment/statement from the protesting driver */
|
||||
comment?: string;
|
||||
/** URL to proof video clip */
|
||||
proofVideoUrl?: string;
|
||||
/** Current status of the protest */
|
||||
status: ProtestStatus;
|
||||
/** ID of the steward/admin who reviewed (if any) */
|
||||
reviewedBy?: string;
|
||||
/** Decision notes from the steward */
|
||||
decisionNotes?: string;
|
||||
/** Timestamp when the protest was filed */
|
||||
filedAt: Date;
|
||||
/** Timestamp when the protest was reviewed */
|
||||
reviewedAt?: Date;
|
||||
}
|
||||
|
||||
export class Protest {
|
||||
private constructor(private readonly props: ProtestProps) {}
|
||||
|
||||
static create(props: ProtestProps): Protest {
|
||||
if (!props.id) throw new Error('Protest ID is required');
|
||||
if (!props.raceId) throw new Error('Race ID is required');
|
||||
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
|
||||
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
|
||||
if (!props.incident) throw new Error('Incident details are required');
|
||||
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
|
||||
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
|
||||
|
||||
return new Protest({
|
||||
...props,
|
||||
status: props.status || 'pending',
|
||||
filedAt: props.filedAt || new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get raceId(): string { return this.props.raceId; }
|
||||
get protestingDriverId(): string { return this.props.protestingDriverId; }
|
||||
get accusedDriverId(): string { return this.props.accusedDriverId; }
|
||||
get incident(): ProtestIncident { return { ...this.props.incident }; }
|
||||
get comment(): string | undefined { return this.props.comment; }
|
||||
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
|
||||
get status(): ProtestStatus { return this.props.status; }
|
||||
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
|
||||
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
|
||||
get filedAt(): Date { return this.props.filedAt; }
|
||||
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status === 'pending';
|
||||
}
|
||||
|
||||
isUnderReview(): boolean {
|
||||
return this.props.status === 'under_review';
|
||||
}
|
||||
|
||||
isResolved(): boolean {
|
||||
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reviewing the protest
|
||||
*/
|
||||
startReview(stewardId: string): Protest {
|
||||
if (!this.isPending()) {
|
||||
throw new Error('Only pending protests can be put under review');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'under_review',
|
||||
reviewedBy: stewardId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uphold the protest (finding the accused guilty)
|
||||
*/
|
||||
uphold(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be upheld');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'upheld',
|
||||
reviewedBy: stewardId,
|
||||
decisionNotes,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the protest (finding no fault)
|
||||
*/
|
||||
dismiss(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be dismissed');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'dismissed',
|
||||
reviewedBy: stewardId,
|
||||
decisionNotes,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the protest (by the protesting driver)
|
||||
*/
|
||||
withdraw(): Protest {
|
||||
if (this.isResolved()) {
|
||||
throw new Error('Cannot withdraw a resolved protest');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'withdrawn',
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,53 @@
|
||||
/**
|
||||
* Domain Entity: Race
|
||||
*
|
||||
*
|
||||
* Represents a race/session in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
|
||||
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
export class Race {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly trackId?: string;
|
||||
readonly car: string;
|
||||
readonly carId?: string;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: RaceStatus;
|
||||
readonly strengthOfField?: number;
|
||||
readonly registeredCount?: number;
|
||||
readonly maxParticipants?: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.scheduledAt = props.scheduledAt;
|
||||
this.track = props.track;
|
||||
this.trackId = props.trackId;
|
||||
this.car = props.car;
|
||||
this.carId = props.carId;
|
||||
this.sessionType = props.sessionType;
|
||||
this.status = props.status;
|
||||
this.strengthOfField = props.strengthOfField;
|
||||
this.registeredCount = props.registeredCount;
|
||||
this.maxParticipants = props.maxParticipants;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,9 +58,14 @@ export class Race {
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType?: SessionType;
|
||||
status?: RaceStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}): Race {
|
||||
this.validate(props);
|
||||
|
||||
@@ -54,9 +74,14 @@ export class Race {
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
trackId: props.trackId,
|
||||
car: props.car,
|
||||
carId: props.carId,
|
||||
sessionType: props.sessionType ?? 'race',
|
||||
status: props.status ?? 'scheduled',
|
||||
strengthOfField: props.strengthOfField,
|
||||
registeredCount: props.registeredCount,
|
||||
maxParticipants: props.maxParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,6 +116,20 @@ export class Race {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the race (move from scheduled to running)
|
||||
*/
|
||||
start(): Race {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new Error('Only scheduled races can be started');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark race as completed
|
||||
*/
|
||||
@@ -127,6 +166,17 @@ export class Race {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SOF and participant count
|
||||
*/
|
||||
updateField(strengthOfField: number, registeredCount: number): Race {
|
||||
return new Race({
|
||||
...this,
|
||||
strengthOfField,
|
||||
registeredCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in the past
|
||||
*/
|
||||
@@ -140,4 +190,11 @@ export class Race {
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is live/running
|
||||
*/
|
||||
isLive(): boolean {
|
||||
return this.status === 'running';
|
||||
}
|
||||
}
|
||||
120
packages/racing/domain/entities/Track.ts
Normal file
120
packages/racing/domain/entities/Track.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Domain Entity: Track
|
||||
*
|
||||
* Represents a racing track/circuit in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
|
||||
export class Track {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly shortName: string;
|
||||
readonly country: string;
|
||||
readonly category: TrackCategory;
|
||||
readonly difficulty: TrackDifficulty;
|
||||
readonly lengthKm: number;
|
||||
readonly turns: number;
|
||||
readonly imageUrl?: string;
|
||||
readonly gameId: string;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
country: string;
|
||||
category: TrackCategory;
|
||||
difficulty: TrackDifficulty;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.shortName = props.shortName;
|
||||
this.country = props.country;
|
||||
this.category = props.category;
|
||||
this.difficulty = props.difficulty;
|
||||
this.lengthKm = props.lengthKm;
|
||||
this.turns = props.turns;
|
||||
this.imageUrl = props.imageUrl;
|
||||
this.gameId = props.gameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Track entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
country: string;
|
||||
category?: TrackCategory;
|
||||
difficulty?: TrackDifficulty;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}): Track {
|
||||
this.validate(props);
|
||||
|
||||
return new Track({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
|
||||
country: props.country,
|
||||
category: props.category ?? 'road',
|
||||
difficulty: props.difficulty ?? 'intermediate',
|
||||
lengthKm: props.lengthKm,
|
||||
turns: props.turns,
|
||||
imageUrl: props.imageUrl,
|
||||
gameId: props.gameId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Track ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Track name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Track country is required');
|
||||
}
|
||||
|
||||
if (props.lengthKm <= 0) {
|
||||
throw new Error('Track length must be positive');
|
||||
}
|
||||
|
||||
if (props.turns < 0) {
|
||||
throw new Error('Track turns cannot be negative');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted length string
|
||||
*/
|
||||
getFormattedLength(): string {
|
||||
return `${this.lengthKm.toFixed(2)} km`;
|
||||
}
|
||||
}
|
||||
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Application Port: ICarRepository
|
||||
*
|
||||
* Repository interface for Car entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Car, CarClass, CarLicense } from '../entities/Car';
|
||||
|
||||
export interface ICarRepository {
|
||||
/**
|
||||
* Find a car by ID
|
||||
*/
|
||||
findById(id: string): Promise<Car | null>;
|
||||
|
||||
/**
|
||||
* Find all cars
|
||||
*/
|
||||
findAll(): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by game ID
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by class
|
||||
*/
|
||||
findByClass(carClass: CarClass): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by license level
|
||||
*/
|
||||
findByLicense(license: CarLicense): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by manufacturer
|
||||
*/
|
||||
findByManufacturer(manufacturer: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Search cars by name
|
||||
*/
|
||||
searchByName(query: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Create a new car
|
||||
*/
|
||||
create(car: Car): Promise<Car>;
|
||||
|
||||
/**
|
||||
* Update an existing car
|
||||
*/
|
||||
update(car: Car): Promise<Car>;
|
||||
|
||||
/**
|
||||
* Delete a car by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a car exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Application Port: ITrackRepository
|
||||
*
|
||||
* Repository interface for Track entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Track, TrackCategory } from '../entities/Track';
|
||||
|
||||
export interface ITrackRepository {
|
||||
/**
|
||||
* Find a track by ID
|
||||
*/
|
||||
findById(id: string): Promise<Track | null>;
|
||||
|
||||
/**
|
||||
* Find all tracks
|
||||
*/
|
||||
findAll(): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by game ID
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by category
|
||||
*/
|
||||
findByCategory(category: TrackCategory): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by country
|
||||
*/
|
||||
findByCountry(country: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Search tracks by name
|
||||
*/
|
||||
searchByName(query: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Create a new track
|
||||
*/
|
||||
create(track: Track): Promise<Track>;
|
||||
|
||||
/**
|
||||
* Update an existing track
|
||||
*/
|
||||
update(track: Track): Promise<Track>;
|
||||
|
||||
/**
|
||||
* Delete a track by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a track exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Domain Service: StrengthOfFieldCalculator
|
||||
*
|
||||
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
|
||||
* SOF is the average rating of all participants in a race.
|
||||
*/
|
||||
|
||||
export interface DriverRating {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface StrengthOfFieldCalculator {
|
||||
/**
|
||||
* Calculate SOF from a list of driver ratings
|
||||
* Returns null if no valid ratings are provided
|
||||
*/
|
||||
calculate(driverRatings: DriverRating[]): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation using simple average
|
||||
*/
|
||||
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
|
||||
calculate(driverRatings: DriverRating[]): number | null {
|
||||
if (driverRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validRatings = driverRatings.filter(dr => dr.rating > 0);
|
||||
|
||||
if (validRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sum = validRatings.reduce((acc, dr) => acc + dr.rating, 0);
|
||||
return Math.round(sum / validRatings.length);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export * from './domain/entities/Standing';
|
||||
export * from './domain/entities/LeagueMembership';
|
||||
export * from './domain/entities/RaceRegistration';
|
||||
export * from './domain/entities/Team';
|
||||
export * from './domain/entities/Track';
|
||||
export * from './domain/entities/Car';
|
||||
|
||||
export * from './domain/repositories/IDriverRepository';
|
||||
export * from './domain/repositories/ILeagueRepository';
|
||||
@@ -16,6 +18,10 @@ export * from './domain/repositories/ILeagueMembershipRepository';
|
||||
export * from './domain/repositories/IRaceRegistrationRepository';
|
||||
export * from './domain/repositories/ITeamRepository';
|
||||
export * from './domain/repositories/ITeamMembershipRepository';
|
||||
export * from './domain/repositories/ITrackRepository';
|
||||
export * from './domain/repositories/ICarRepository';
|
||||
|
||||
export * from './domain/services/StrengthOfFieldCalculator';
|
||||
|
||||
export * from './application/mappers/EntityMappers';
|
||||
export * from './application/dto/DriverDTO';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryCarRepository
|
||||
*
|
||||
* In-memory implementation of ICarRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Car, CarClass, CarLicense } from '@gridpilot/racing/domain/entities/Car';
|
||||
import type { ICarRepository } from '@gridpilot/racing/domain/repositories/ICarRepository';
|
||||
|
||||
export class InMemoryCarRepository implements ICarRepository {
|
||||
private cars: Map<string, Car>;
|
||||
|
||||
constructor(seedData?: Car[]) {
|
||||
this.cars = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(car => {
|
||||
this.cars.set(car.id, car);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Car | null> {
|
||||
return this.cars.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Car[]> {
|
||||
return Array.from(this.cars.values());
|
||||
}
|
||||
|
||||
async findByGameId(gameId: string): Promise<Car[]> {
|
||||
return Array.from(this.cars.values())
|
||||
.filter(car => car.gameId === gameId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async findByClass(carClass: CarClass): Promise<Car[]> {
|
||||
return Array.from(this.cars.values())
|
||||
.filter(car => car.carClass === carClass)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async findByLicense(license: CarLicense): Promise<Car[]> {
|
||||
return Array.from(this.cars.values())
|
||||
.filter(car => car.license === license)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async findByManufacturer(manufacturer: string): Promise<Car[]> {
|
||||
const lowerManufacturer = manufacturer.toLowerCase();
|
||||
return Array.from(this.cars.values())
|
||||
.filter(car => car.manufacturer.toLowerCase() === lowerManufacturer)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async searchByName(query: string): Promise<Car[]> {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Array.from(this.cars.values())
|
||||
.filter(car =>
|
||||
car.name.toLowerCase().includes(lowerQuery) ||
|
||||
car.shortName.toLowerCase().includes(lowerQuery) ||
|
||||
car.manufacturer.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async create(car: Car): Promise<Car> {
|
||||
if (await this.exists(car.id)) {
|
||||
throw new Error(`Car with ID ${car.id} already exists`);
|
||||
}
|
||||
|
||||
this.cars.set(car.id, car);
|
||||
return car;
|
||||
}
|
||||
|
||||
async update(car: Car): Promise<Car> {
|
||||
if (!await this.exists(car.id)) {
|
||||
throw new Error(`Car with ID ${car.id} not found`);
|
||||
}
|
||||
|
||||
this.cars.set(car.id, car);
|
||||
return car;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`Car with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.cars.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.cars.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTrackRepository
|
||||
*
|
||||
* In-memory implementation of ITrackRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Track, TrackCategory } from '@gridpilot/racing/domain/entities/Track';
|
||||
import type { ITrackRepository } from '@gridpilot/racing/domain/repositories/ITrackRepository';
|
||||
|
||||
export class InMemoryTrackRepository implements ITrackRepository {
|
||||
private tracks: Map<string, Track>;
|
||||
|
||||
constructor(seedData?: Track[]) {
|
||||
this.tracks = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(track => {
|
||||
this.tracks.set(track.id, track);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Track | null> {
|
||||
return this.tracks.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Track[]> {
|
||||
return Array.from(this.tracks.values());
|
||||
}
|
||||
|
||||
async findByGameId(gameId: string): Promise<Track[]> {
|
||||
return Array.from(this.tracks.values())
|
||||
.filter(track => track.gameId === gameId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async findByCategory(category: TrackCategory): Promise<Track[]> {
|
||||
return Array.from(this.tracks.values())
|
||||
.filter(track => track.category === category)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async findByCountry(country: string): Promise<Track[]> {
|
||||
return Array.from(this.tracks.values())
|
||||
.filter(track => track.country.toLowerCase() === country.toLowerCase())
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async searchByName(query: string): Promise<Track[]> {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Array.from(this.tracks.values())
|
||||
.filter(track =>
|
||||
track.name.toLowerCase().includes(lowerQuery) ||
|
||||
track.shortName.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async create(track: Track): Promise<Track> {
|
||||
if (await this.exists(track.id)) {
|
||||
throw new Error(`Track with ID ${track.id} already exists`);
|
||||
}
|
||||
|
||||
this.tracks.set(track.id, track);
|
||||
return track;
|
||||
}
|
||||
|
||||
async update(track: Track): Promise<Track> {
|
||||
if (!await this.exists(track.id)) {
|
||||
throw new Error(`Track with ID ${track.id} not found`);
|
||||
}
|
||||
|
||||
this.tracks.set(track.id, track);
|
||||
return track;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`Track with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.tracks.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.tracks.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user