diff --git a/apps/companion/main/di-config.ts b/apps/companion/main/di-config.ts new file mode 100644 index 000000000..0eb1b317e --- /dev/null +++ b/apps/companion/main/di-config.ts @@ -0,0 +1,286 @@ +import 'reflect-metadata'; +import { container, Lifecycle } from 'tsyringe'; +import { DI_TOKENS } from './di-tokens'; +import * as path from 'path'; +import * as os from 'os'; + +// Domain & Application +import type { SessionRepositoryPort } from '@gridpilot/automation/application/ports/SessionRepositoryPort'; +import type { IBrowserAutomation } from '@gridpilot/automation/application/ports/ScreenAutomationPort'; +import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort'; +import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort'; +import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort'; +import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort'; +import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase'; +import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase'; +import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase'; +import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase'; +import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase'; +import { OverlaySyncService } from '@gridpilot/automation/application/services/OverlaySyncService'; + +// Infrastructure +import { InMemorySessionRepository } from '@gridpilot/automation/infrastructure/repositories/InMemorySessionRepository'; +import { + MockBrowserAutomationAdapter, + PlaywrightAutomationAdapter, + AutomationAdapterMode, + FixtureServer, +} from '@gridpilot/automation/infrastructure/adapters/automation'; +import { MockAutomationEngineAdapter } from '@gridpilot/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter'; +import { AutomationEngineAdapter } from '@gridpilot/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter'; +import { PinoLogAdapter } from '@gridpilot/automation/infrastructure/adapters/logging/PinoLogAdapter'; +import { NoOpLogAdapter } from '@gridpilot/automation/infrastructure/adapters/logging/NoOpLogAdapter'; +import { + loadAutomationConfig, + getAutomationMode, + AutomationMode, + BrowserModeConfigLoader, +} from '@gridpilot/automation/infrastructure/config'; +import { loadLoggingConfig } from '@gridpilot/automation/infrastructure/config/LoggingConfig'; + +// Electron app safe wrapper +let electronApp: { + getAppPath?: () => string; + getPath?: (name: string) => string; + isPackaged?: boolean; +} | undefined; + +try { + const _electron = require('electron'); + electronApp = _electron?.app; +} catch { + electronApp = undefined; +} + +/** + * Resolve session data path with test-tolerance + */ +export function resolveSessionDataPath(): string { + const userDataPath = + electronApp?.getPath?.('userData') ?? path.join(process.cwd(), 'userData') ?? os.tmpdir(); + return path.join(userDataPath, 'iracing-session'); +} + +/** + * Resolve template path with test-tolerance + */ +export function resolveTemplatePath(): string { + const appPath = electronApp?.getAppPath?.() ?? process.cwd(); + const isPackaged = electronApp?.isPackaged ?? false; + + if (isPackaged) { + return path.join(appPath, 'resources/templates/iracing'); + } + + return path.join(appPath, '../../resources/templates/iracing'); +} + +/** + * Determine adapter mode based on environment + */ +function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode { + return envMode === 'test' ? 'mock' : 'real'; +} + +/** + * Check if running in fixture hosted mode + */ +function isFixtureHostedMode(): boolean { + return process.env.NODE_ENV === 'test' && process.env.COMPANION_FIXTURE_HOSTED === '1'; +} + +/** + * Configure the DI container with all bindings + */ +export function configureDIContainer(): void { + // Clear any existing registrations + container.clearInstances(); + + // Configuration values + const automationMode = getAutomationMode(); + const config = loadAutomationConfig(); + const loggingConfig = loadLoggingConfig(); + const fixtureMode = isFixtureHostedMode(); + + container.registerInstance(DI_TOKENS.AutomationMode, automationMode); + + // Logger (singleton) + const logger = process.env.NODE_ENV === 'test' ? new NoOpLogAdapter() : new PinoLogAdapter(loggingConfig); + container.registerInstance(DI_TOKENS.Logger, logger); + + // Browser Mode Config Loader (singleton) + const browserModeConfigLoader = new BrowserModeConfigLoader(); + if (process.env.NODE_ENV === 'development') { + browserModeConfigLoader.setDevelopmentMode('headed'); + } + container.registerInstance(DI_TOKENS.BrowserModeConfigLoader, browserModeConfigLoader); + + // Session Repository (singleton) + container.register( + DI_TOKENS.SessionRepository, + { useClass: InMemorySessionRepository }, + { lifecycle: Lifecycle.Singleton } + ); + + // Browser Automation Adapter (singleton) + const browserModeConfig = browserModeConfigLoader.load(); + const adapterMode = getAdapterMode(config.mode); + const absoluteTemplatePath = resolveTemplatePath(); + const sessionDataPath = resolveSessionDataPath(); + const safeAppPath = electronApp?.getAppPath?.() ?? process.cwd(); + const safeIsPackaged = electronApp?.isPackaged ?? false; + + logger.debug('Resolved paths', { + absoluteTemplatePath, + sessionDataPath, + appPath: safeAppPath, + isPackaged: safeIsPackaged, + cwd: process.cwd(), + }); + + logger.info('Creating browser automation adapter', { + envMode: config.mode, + adapterMode, + browserMode: browserModeConfig.mode, + browserModeSource: browserModeConfig.source, + }); + + let browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter; + + switch (config.mode) { + case 'production': + case 'development': + browserAutomation = new PlaywrightAutomationAdapter( + { + headless: browserModeConfig.mode === 'headless', + mode: adapterMode, + userDataDir: sessionDataPath, + baseUrl: fixtureMode ? 'http://localhost:3456' : '', + }, + logger.child({ adapter: 'Playwright', mode: adapterMode }), + browserModeConfigLoader + ); + break; + + case 'test': + default: + if (fixtureMode) { + browserAutomation = new PlaywrightAutomationAdapter( + { + headless: browserModeConfig.mode === 'headless', + timeout: config.defaultTimeout ?? 10_000, + baseUrl: 'http://localhost:3456', + mode: 'real', + userDataDir: sessionDataPath, + }, + logger.child({ adapter: 'Playwright', mode: 'real' }), + browserModeConfigLoader + ); + } else { + browserAutomation = new MockBrowserAutomationAdapter(); + } + break; + } + + container.registerInstance( + DI_TOKENS.BrowserAutomation, + browserAutomation + ); + + // Automation Engine (singleton) + const sessionRepository = container.resolve(DI_TOKENS.SessionRepository); + let automationEngine: AutomationEnginePort; + + if (fixtureMode) { + automationEngine = new AutomationEngineAdapter( + browserAutomation as any, + sessionRepository + ); + } else { + automationEngine = new MockAutomationEngineAdapter( + browserAutomation, + sessionRepository + ); + } + + container.registerInstance( + DI_TOKENS.AutomationEngine, + automationEngine + ); + + // Fixture Server (singleton, nullable) + if (fixtureMode) { + container.registerInstance(DI_TOKENS.FixtureServer, new FixtureServer()); + } + + // Use Cases - create singleton instance directly + const startAutomationUseCase = new StartAutomationSessionUseCase( + automationEngine, + browserAutomation, + sessionRepository + ); + container.registerInstance(DI_TOKENS.StartAutomationUseCase, startAutomationUseCase); + + // Authentication-related use cases (only if adapter supports it) + if (browserAutomation instanceof PlaywrightAutomationAdapter && !fixtureMode) { + const authService = browserAutomation as AuthenticationServicePort; + + container.registerInstance( + DI_TOKENS.CheckAuthenticationUseCase, + new CheckAuthenticationUseCase(authService) + ); + + container.registerInstance( + DI_TOKENS.InitiateLoginUseCase, + new InitiateLoginUseCase(authService) + ); + + container.registerInstance( + DI_TOKENS.ClearSessionUseCase, + new ClearSessionUseCase(authService) + ); + + container.registerInstance( + DI_TOKENS.AuthenticationService, + authService + ); + } + + // Overlay Sync Service - create singleton instance directly + const lifecycleEmitter = browserAutomation as any; + const publisher = { + publish: async (_event: any) => { + try { + logger.debug?.('OverlaySyncPublisher.publish', _event); + } catch { + // swallow + } + }, + } as any; + const overlaySyncService = new OverlaySyncService({ + lifecycleEmitter, + publisher, + logger, + }); + container.registerInstance(DI_TOKENS.OverlaySyncPort, overlaySyncService); + + logger.info('DI container configured', { + automationMode: config.mode, + sessionRepositoryType: 'InMemorySessionRepository', + browserAutomationType: browserAutomation.constructor.name, + }); +} + +/** + * Reset the container (for testing) + */ +export function resetDIContainer(): void { + container.clearInstances(); +} + +/** + * Get the TSyringe container instance + */ +export function getDIContainer() { + return container; +} \ No newline at end of file diff --git a/apps/companion/main/di-container.ts b/apps/companion/main/di-container.ts index ffd45b2a5..80f58c0e6 100644 --- a/apps/companion/main/di-container.ts +++ b/apps/companion/main/di-container.ts @@ -1,37 +1,23 @@ -import { app } from 'electron'; -import * as path from 'path'; -import { InMemorySessionRepository } from '@/packages/automation/infrastructure/repositories/InMemorySessionRepository'; -import { - MockBrowserAutomationAdapter, - PlaywrightAutomationAdapter, - AutomationAdapterMode, - FixtureServer, -} from '@/packages/automation/infrastructure/adapters/automation'; -import { MockAutomationEngineAdapter } from '@/packages/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter'; -import { AutomationEngineAdapter } from '@/packages/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter'; -import { StartAutomationSessionUseCase } from '@/packages/automation/application/use-cases/StartAutomationSessionUseCase'; -import { CheckAuthenticationUseCase } from '@/packages/automation/application/use-cases/CheckAuthenticationUseCase'; -import { InitiateLoginUseCase } from '@/packages/automation/application/use-cases/InitiateLoginUseCase'; -import { ClearSessionUseCase } from '@/packages/automation/application/use-cases/ClearSessionUseCase'; -import { ConfirmCheckoutUseCase } from '@/packages/automation/application/use-cases/ConfirmCheckoutUseCase'; -import { - loadAutomationConfig, - getAutomationMode, - AutomationMode, - BrowserModeConfigLoader, -} from '@/packages/automation/infrastructure/config'; -import { PinoLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/PinoLogAdapter'; -import { NoOpLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/NoOpLogAdapter'; -import { loadLoggingConfig } from '@/packages/automation/infrastructure/config/LoggingConfig'; +import 'reflect-metadata'; +import { configureDIContainer, resetDIContainer, getDIContainer, resolveSessionDataPath, resolveTemplatePath } from './di-config'; +import { DI_TOKENS } from './di-tokens'; +import { PlaywrightAutomationAdapter, FixtureServer } from '@gridpilot/automation/infrastructure/adapters/automation'; +import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase'; +import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase'; +import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase'; +import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase'; +import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase'; +import { getAutomationMode, AutomationMode, BrowserModeConfigLoader } from '@gridpilot/automation/infrastructure/config'; import type { SessionRepositoryPort } from '@gridpilot/automation/application/ports/SessionRepositoryPort'; -import type { ScreenAutomationPort } from '@gridpilot/automation/application/ports/ScreenAutomationPort'; +import type { IBrowserAutomation } from '@gridpilot/automation/application/ports/ScreenAutomationPort'; import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort'; import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort'; import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort'; import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort'; import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort'; -import type { IAutomationLifecycleEmitter } from '@/packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter'; -import { OverlaySyncService } from '@/packages/automation/application/services/OverlaySyncService'; + +// Re-export for backward compatibility +export { resolveSessionDataPath, resolveTemplatePath }; export interface BrowserConnectionResult { success: boolean; @@ -39,275 +25,50 @@ export interface BrowserConnectionResult { } /** - * Test-tolerant resolution of the path to store persistent browser session data. - * When Electron's `app` is unavailable (e.g., in vitest), fall back to safe defaults. - * - * @returns Absolute path to the iracing session directory + * Check if running in fixture hosted mode */ -import * as os from 'os'; - -// Use a runtime-safe wrapper around Electron's `app` so importing this module -// in a plain Node/Vitest environment does not throw. We intentionally avoid -// top-level `app.*` calls without checks. (test-tolerance) -let electronApp: { - getAppPath?: () => string; - getPath?: (name: string) => string; - isPackaged?: boolean; -} | undefined; - -try { - // Require inside try/catch to avoid module resolution errors in test env. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const _electron = require('electron'); - electronApp = _electron?.app; -} catch { - electronApp = undefined; -} - -export function resolveSessionDataPath(): string { - // Prefer Electron userData if available, otherwise use os.tmpdir() as a safe fallback. - const userDataPath = - electronApp?.getPath?.('userData') ?? path.join(process.cwd(), 'userData') ?? os.tmpdir(); - return path.join(userDataPath, 'iracing-session'); -} - -/** - * Resolve the absolute path to the template directory. - * Handles both development and production (packaged) Electron environments. - * - * @returns Absolute path to the iracing templates directory - */ -export function resolveTemplatePath(): string { - // Test-tolerant resolution of template path. Use Electron app when available, - // otherwise fall back to process.cwd(). Preserve original runtime behavior when - // Electron's app is present (test-tolerance). - const appPath = electronApp?.getAppPath?.() ?? process.cwd(); - const isPackaged = electronApp?.isPackaged ?? false; - - if (isPackaged) { - return path.join(appPath, 'resources/templates/iracing'); - } - - // Development or unknown environment: prefer project-relative resources. - return path.join(appPath, '../../resources/templates/iracing'); -} - -/** - * Create logger based on environment configuration. - * In test environment, returns NoOpLogAdapter for silent logging. - */ -function createLogger(): LoggerPort { - const config = loadLoggingConfig(); - - if (process.env.NODE_ENV === 'test') { - return new NoOpLogAdapter(); - } - - return new PinoLogAdapter(config); -} - -/** - * Determine the adapter mode based on environment. - * - 'production' → 'real' (uses iRacing website selectors) - * - 'development' → 'real' (uses iRacing website selectors) - * - 'test' → 'mock' (uses data-* attribute selectors) - */ -function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode { - return envMode === 'test' ? 'mock' : 'real'; -} - function isFixtureHostedMode(): boolean { return process.env.NODE_ENV === 'test' && process.env.COMPANION_FIXTURE_HOSTED === '1'; } /** - * Create screen automation adapter based on configuration mode. - * - * Mode mapping: - * - 'production' → PlaywrightAutomationAdapter with mode='real' for iRacing website - * - 'development' → PlaywrightAutomationAdapter with mode='real' for iRacing website - * - 'test' → MockBrowserAutomationAdapter - * - * @param mode - The automation mode from configuration - * @param logger - Logger instance for the adapter - * @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService) + * DIContainer - Facade over TSyringe container for backward compatibility */ -function createBrowserAutomationAdapter( - mode: AutomationMode, - logger: ILogger, - browserModeConfigLoader: BrowserModeConfigLoader, - options?: { fixtureBaseUrl?: string; forcePlaywrightReal?: boolean } -): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter { - const config = loadAutomationConfig(); - - // Resolve absolute template path for Electron or fallback environments - const absoluteTemplatePath = resolveTemplatePath(); - const sessionDataPath = resolveSessionDataPath(); - - // Use safe accessors for app metadata to avoid throwing in test env (test-tolerance). - const safeAppPath = electronApp?.getAppPath?.() ?? process.cwd(); - const safeIsPackaged = electronApp?.isPackaged ?? false; - - logger.debug('Resolved paths', { - absoluteTemplatePath, - sessionDataPath, - appPath: safeAppPath, - isPackaged: safeIsPackaged, - cwd: process.cwd() - }); - - const adapterMode = getAdapterMode(mode); - - // Get browser mode configuration from provided loader - const browserModeConfig = browserModeConfigLoader.load(); - - logger.info('Creating browser automation adapter', { - envMode: mode, - adapterMode, - browserMode: browserModeConfig.mode, - browserModeSource: browserModeConfig.source, - }); - - switch (mode) { - case 'production': - case 'development': - return new PlaywrightAutomationAdapter( - { - headless: browserModeConfig.mode === 'headless', - mode: adapterMode, - userDataDir: sessionDataPath, - baseUrl: options?.fixtureBaseUrl ?? '', - }, - logger.child({ adapter: 'Playwright', mode: adapterMode }), - browserModeConfigLoader - ); - - case 'test': - default: - if (options?.forcePlaywrightReal) { - return new PlaywrightAutomationAdapter( - { - headless: browserModeConfig.mode === 'headless', - timeout: config.defaultTimeout ?? 10_000, - baseUrl: options.fixtureBaseUrl ?? '', - mode: 'real', - userDataDir: sessionDataPath, - }, - logger.child({ adapter: 'Playwright', mode: 'real' }), - browserModeConfigLoader - ); - } - return new MockBrowserAutomationAdapter(); - } -} - export class DIContainer { private static instance: DIContainer; - - private logger: LoggerPort; - private sessionRepository!: SessionRepositoryPort; - private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter; - private automationEngine!: AutomationEnginePort; - private fixtureServer: FixtureServer | null = null; - private startAutomationUseCase!: StartAutomationSessionUseCase; - private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null; - private initiateLoginUseCase: InitiateLoginUseCase | null = null; - private clearSessionUseCase: ClearSessionUseCase | null = null; - private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null; - private automationMode: AutomationMode; - private browserModeConfigLoader: BrowserModeConfigLoader; - private overlaySyncService?: OverlaySyncService; - private initialized = false; + private automationMode: AutomationMode; + private fixtureServer: FixtureServer | null = null; + private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null; + private constructor() { - // Initialize logger first - it's needed by other components - this.logger = createLogger(); - this.automationMode = getAutomationMode(); - this.logger.info('DIContainer initializing', { - automationMode: this.automationMode, - nodeEnv: process.env.NODE_ENV - }); - - // Defer heavy initialization that may touch Electron/app paths until first use. - // Keep BrowserModeConfigLoader available immediately so callers can inspect it. - this.browserModeConfigLoader = new BrowserModeConfigLoader(); - // Ensure the DIContainer exposes a development-visible default in interactive dev environment. - // Some integration/smoke tests expect the DI-provided loader to default to 'headed' in development. - if (process.env.NODE_ENV === 'development') { - this.browserModeConfigLoader.setDevelopmentMode('headed'); - } } - + /** - * Lazily perform initialization that may access Electron APIs or filesystem. - * Called on first demand by methods that require the heavy components. + * Lazily initialize the TSyringe container */ private ensureInitialized(): void { if (this.initialized) return; - - const config = loadAutomationConfig(); - - this.sessionRepository = new InMemorySessionRepository(); - - const fixtureMode = isFixtureHostedMode(); - const fixtureBaseUrl = fixtureMode ? 'http://localhost:3456' : undefined; - - this.browserAutomation = createBrowserAutomationAdapter( - config.mode, - this.logger, - this.browserModeConfigLoader, - { fixtureBaseUrl, forcePlaywrightReal: fixtureMode } - ); - - if (fixtureMode) { - this.fixtureServer = new FixtureServer(); - this.automationEngine = new AutomationEngineAdapter( - this.browserAutomation as IScreenAutomation, - this.sessionRepository - ); - } else { - this.automationEngine = new MockAutomationEngineAdapter( - this.browserAutomation, - this.sessionRepository - ); - } - - this.startAutomationUseCase = new StartAutomationSessionUseCase( - this.automationEngine, - this.browserAutomation, - this.sessionRepository - ); - - if (this.browserAutomation instanceof PlaywrightAutomationAdapter && !fixtureMode) { - const authService = this.browserAutomation as IAuthenticationService; - this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService); - this.initiateLoginUseCase = new InitiateLoginUseCase(authService); - this.clearSessionUseCase = new ClearSessionUseCase(authService); - } else { - this.checkAuthenticationUseCase = null; - this.initiateLoginUseCase = null; - this.clearSessionUseCase = null; - } - - this.logger.info('DIContainer initialized', { - automationMode: config.mode, - sessionRepositoryType: 'InMemorySessionRepository', - browserAutomationType: this.getBrowserAutomationType(config.mode) + + configureDIContainer(); + + const logger = getDIContainer().resolve(DI_TOKENS.Logger); + logger.info('DIContainer initialized', { + automationMode: this.automationMode, + nodeEnv: process.env.NODE_ENV, }); - - this.initialized = true; - } - - private getBrowserAutomationType(mode: AutomationMode): string { - switch (mode) { - case 'production': - case 'development': - return 'PlaywrightAutomationAdapter'; - case 'test': - default: - return 'MockBrowserAutomationAdapter'; + + const fixtureMode = isFixtureHostedMode(); + if (fixtureMode) { + try { + this.fixtureServer = getDIContainer().resolve(DI_TOKENS.FixtureServer); + } catch { + // FixtureServer not registered in non-fixture mode + } } + + this.initialized = true; } public static getInstance(): DIContainer { @@ -319,91 +80,110 @@ export class DIContainer { public getStartAutomationUseCase(): StartAutomationSessionUseCase { this.ensureInitialized(); - return this.startAutomationUseCase; + return getDIContainer().resolve(DI_TOKENS.StartAutomationUseCase); } public getSessionRepository(): SessionRepositoryPort { this.ensureInitialized(); - return this.sessionRepository; + return getDIContainer().resolve(DI_TOKENS.SessionRepository); } - + public getAutomationEngine(): AutomationEnginePort { this.ensureInitialized(); - return this.automationEngine; + return getDIContainer().resolve(DI_TOKENS.AutomationEngine); } public getAutomationMode(): AutomationMode { return this.automationMode; } - public getBrowserAutomation(): ScreenAutomationPort { + public getBrowserAutomation(): IBrowserAutomation { this.ensureInitialized(); - return this.browserAutomation; + return getDIContainer().resolve(DI_TOKENS.BrowserAutomation); } - + public getLogger(): LoggerPort { - return this.logger; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.Logger); } public getCheckAuthenticationUseCase(): CheckAuthenticationUseCase | null { this.ensureInitialized(); - return this.checkAuthenticationUseCase; + try { + return getDIContainer().resolve(DI_TOKENS.CheckAuthenticationUseCase); + } catch { + return null; + } } public getInitiateLoginUseCase(): InitiateLoginUseCase | null { this.ensureInitialized(); - return this.initiateLoginUseCase; + try { + return getDIContainer().resolve(DI_TOKENS.InitiateLoginUseCase); + } catch { + return null; + } } public getClearSessionUseCase(): ClearSessionUseCase | null { this.ensureInitialized(); - return this.clearSessionUseCase; + try { + return getDIContainer().resolve(DI_TOKENS.ClearSessionUseCase); + } catch { + return null; + } } public getAuthenticationService(): AuthenticationServicePort | null { this.ensureInitialized(); - if (this.browserAutomation instanceof PlaywrightAutomationAdapter) { - return this.browserAutomation as AuthenticationServicePort; + try { + return getDIContainer().resolve(DI_TOKENS.AuthenticationService); + } catch { + return null; } - return null; } - public setConfirmCheckoutUseCase( - checkoutConfirmationPort: CheckoutConfirmationPort - ): void { + public setConfirmCheckoutUseCase(checkoutConfirmationPort: CheckoutConfirmationPort): void { this.ensureInitialized(); - // Create ConfirmCheckoutUseCase with checkout service from browser automation - // and the provided confirmation port + const browserAutomation = getDIContainer().resolve(DI_TOKENS.BrowserAutomation); this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase( - this.browserAutomation as any, // implements ICheckoutService + browserAutomation as any, checkoutConfirmationPort ); } public getConfirmCheckoutUseCase(): ConfirmCheckoutUseCase | null { - this.ensureInitialized(); return this.confirmCheckoutUseCase; } - /** - * Initialize automation connection based on mode. - * In production/development mode, connects via Playwright browser automation. - * In test mode, returns success immediately (no connection needed). - */ + public getBrowserModeConfigLoader(): BrowserModeConfigLoader { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.BrowserModeConfigLoader); + } + + public getOverlaySyncPort(): OverlaySyncPort { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.OverlaySyncPort); + } + public async initializeBrowserConnection(): Promise { this.ensureInitialized(); const fixtureMode = isFixtureHostedMode(); - this.logger.info('Initializing automation connection', { mode: this.automationMode, fixtureMode }); + const logger = this.getLogger(); + logger.info('Initializing automation connection', { mode: this.automationMode, fixtureMode }); + if (this.automationMode === 'production' || this.automationMode === 'development' || fixtureMode) { try { if (fixtureMode && this.fixtureServer && !this.fixtureServer.isRunning()) { await this.fixtureServer.start(); } - const playwrightAdapter = this.browserAutomation as PlaywrightAutomationAdapter; + const browserAutomation = this.getBrowserAutomation(); + const playwrightAdapter = browserAutomation as PlaywrightAutomationAdapter; const result = await playwrightAdapter.connect(); + if (!result.success) { - this.logger.error( + logger.error( 'Automation connection failed', new Error(result.error || 'Unknown error'), { mode: this.automationMode } @@ -416,7 +196,7 @@ export class DIContainer { if (!isConnected || !page) { const errorMsg = 'Browser not connected'; - this.logger.error( + logger.error( 'Automation connection reported success but has no usable page', new Error(errorMsg), { mode: this.automationMode, isConnected, hasPage: !!page } @@ -424,143 +204,77 @@ export class DIContainer { return { success: false, error: errorMsg }; } - this.logger.info('Automation connection established', { + logger.info('Automation connection established', { mode: this.automationMode, - adapter: 'Playwright' + adapter: 'Playwright', }); return { success: true }; } catch (error) { - const errorMsg = - error instanceof Error ? error.message : 'Failed to initialize Playwright'; - this.logger.error( + const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Playwright'; + logger.error( 'Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: this.automationMode } ); return { success: false, - error: errorMsg + error: errorMsg, }; } } - - this.logger.debug('Test mode - no automation connection needed'); + + logger.debug('Test mode - no automation connection needed'); return { success: true }; } - /** - * Shutdown the container and cleanup resources. - * Should be called when the application is closing. - */ public async shutdown(): Promise { this.ensureInitialized(); - this.logger.info('DIContainer shutting down'); - - if (this.browserAutomation && 'disconnect' in this.browserAutomation) { + const logger = this.getLogger(); + logger.info('DIContainer shutting down'); + + const browserAutomation = this.getBrowserAutomation(); + if (browserAutomation && 'disconnect' in browserAutomation) { try { - await (this.browserAutomation as PlaywrightAutomationAdapter).disconnect(); - this.logger.info('Automation adapter disconnected'); + await (browserAutomation as PlaywrightAutomationAdapter).disconnect(); + logger.info('Automation adapter disconnected'); } catch (error) { - this.logger.error('Error disconnecting automation adapter', error instanceof Error ? error : new Error('Unknown error')); + logger.error( + 'Error disconnecting automation adapter', + error instanceof Error ? error : new Error('Unknown error') + ); } } if (this.fixtureServer && this.fixtureServer.isRunning()) { try { await this.fixtureServer.stop(); - this.logger.info('FixtureServer stopped'); + logger.info('FixtureServer stopped'); } catch (error) { - this.logger.error('Error stopping FixtureServer', error instanceof Error ? error : new Error('Unknown error')); + logger.error('Error stopping FixtureServer', error instanceof Error ? error : new Error('Unknown error')); } finally { this.fixtureServer = null; } } - - this.logger.info('DIContainer shutdown complete'); + + logger.info('DIContainer shutdown complete'); } - /** - * Get the browser mode configuration loader. - * Provides access to runtime browser mode control (headed/headless). - */ - public getBrowserModeConfigLoader(): BrowserModeConfigLoader { - return this.browserModeConfigLoader; - } - - public getOverlaySyncPort(): OverlaySyncPort { - this.ensureInitialized(); - if (!this.overlaySyncService) { - // Use the browser automation adapter as the lifecycle emitter when available. - const lifecycleEmitter = this.browserAutomation as unknown as IAutomationLifecycleEmitter; - // Lightweight in-process publisher (best-effort no-op). The ipc handlers will forward lifecycle events to renderer. - const publisher = { - publish: async (_event: any) => { - try { - this.logger.debug?.('OverlaySyncPublisher.publish', _event); - } catch { - // swallow - } - } - } as any; - this.overlaySyncService = new OverlaySyncService({ - lifecycleEmitter, - publisher, - logger: this.logger - }); - } - return this.overlaySyncService; - } - - /** - * Recreate browser automation and related use-cases from the current - * BrowserModeConfigLoader state. This allows runtime changes to the - * development-mode headed/headless setting to take effect without - * restarting the whole process. - */ public refreshBrowserAutomation(): void { this.ensureInitialized(); - const config = loadAutomationConfig(); - - // Recreate browser automation adapter using current loader state - this.browserAutomation = createBrowserAutomationAdapter( - config.mode, - this.logger, - this.browserModeConfigLoader - ); - - // Recreate automation engine and start use case to pick up new adapter - this.automationEngine = new MockAutomationEngineAdapter( - this.browserAutomation, - this.sessionRepository - ); - - this.startAutomationUseCase = new StartAutomationSessionUseCase( - this.automationEngine, - this.browserAutomation, - this.sessionRepository - ); - - // Recreate authentication use-cases if adapter supports them, otherwise clear - if (this.browserAutomation instanceof PlaywrightAutomationAdapter) { - const authService = this.browserAutomation as AuthenticationServicePort; - this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService); - this.initiateLoginUseCase = new InitiateLoginUseCase(authService); - this.clearSessionUseCase = new ClearSessionUseCase(authService); - } else { - this.checkAuthenticationUseCase = null; - this.initiateLoginUseCase = null; - this.clearSessionUseCase = null; - } - - this.logger.info('Browser automation refreshed from updated BrowserModeConfigLoader', { - browserMode: this.browserModeConfigLoader.load().mode + // Reconfigure the entire container to pick up new browser mode settings + resetDIContainer(); + this.initialized = false; + this.ensureInitialized(); + + const logger = this.getLogger(); + const browserModeConfigLoader = this.getBrowserModeConfigLoader(); + logger.info('Browser automation refreshed from updated BrowserModeConfigLoader', { + browserMode: browserModeConfigLoader.load().mode, }); } - /** - * Reset the singleton instance (useful for testing with different configurations). - */ public static resetInstance(): void { + resetDIContainer(); DIContainer.instance = undefined as unknown as DIContainer; } } \ No newline at end of file diff --git a/apps/companion/main/di-tokens.ts b/apps/companion/main/di-tokens.ts new file mode 100644 index 000000000..c7fc32cc7 --- /dev/null +++ b/apps/companion/main/di-tokens.ts @@ -0,0 +1,35 @@ +/** + * Dependency Injection tokens for TSyringe container + */ + +export const DI_TOKENS = { + // Core Services + Logger: Symbol.for('LoggerPort'), + + // Repositories + SessionRepository: Symbol.for('SessionRepositoryPort'), + + // Adapters + BrowserAutomation: Symbol.for('ScreenAutomationPort'), + AutomationEngine: Symbol.for('AutomationEnginePort'), + AuthenticationService: Symbol.for('AuthenticationServicePort'), + + // Configuration + AutomationMode: Symbol.for('AutomationMode'), + BrowserModeConfigLoader: Symbol.for('BrowserModeConfigLoader'), + + // Use Cases + StartAutomationUseCase: Symbol.for('StartAutomationSessionUseCase'), + CheckAuthenticationUseCase: Symbol.for('CheckAuthenticationUseCase'), + InitiateLoginUseCase: Symbol.for('InitiateLoginUseCase'), + ClearSessionUseCase: Symbol.for('ClearSessionUseCase'), + ConfirmCheckoutUseCase: Symbol.for('ConfirmCheckoutUseCase'), + + // Services + OverlaySyncPort: Symbol.for('OverlaySyncPort'), + + // Infrastructure + FixtureServer: Symbol.for('FixtureServer'), +} as const; + +export type DITokens = typeof DI_TOKENS; \ No newline at end of file diff --git a/apps/website/lib/di-config.ts b/apps/website/lib/di-config.ts new file mode 100644 index 000000000..d3e42c00b --- /dev/null +++ b/apps/website/lib/di-config.ts @@ -0,0 +1,925 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; +import { DI_TOKENS } from './di-tokens'; + +// Domain entities and repositories +import { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; +import { Protest } from '@gridpilot/racing/domain/entities/Protest'; +import { Game } from '@gridpilot/racing/domain/entities/Game'; +import { Season } from '@gridpilot/racing/domain/entities/Season'; +import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership'; + +import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; +import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository'; +import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository'; +import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository'; +import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository'; +import type { IProtestRepository } from '@gridpilot/racing/domain/repositories/IProtestRepository'; +import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository'; +import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository'; +import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository'; +import type { ITrackRepository } from '@gridpilot/racing/domain/repositories/ITrackRepository'; +import type { ICarRepository } from '@gridpilot/racing/domain/repositories/ICarRepository'; +import type { + ITeamRepository, + ITeamMembershipRepository, + IRaceRegistrationRepository, +} from '@gridpilot/racing'; +import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository'; +import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository'; +import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository'; +import type { ImageServicePort } from '@gridpilot/media'; + +// Notifications +import type { INotificationRepository, INotificationPreferenceRepository } from '@gridpilot/notifications/application'; +import { + SendNotificationUseCase, + MarkNotificationReadUseCase, + GetUnreadNotificationsQuery +} from '@gridpilot/notifications/application'; +import { + InMemoryNotificationRepository, + InMemoryNotificationPreferenceRepository, + NotificationGatewayRegistry, + InAppNotificationAdapter, +} from '@gridpilot/notifications/infrastructure'; + +// Infrastructure repositories +import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository'; +import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository'; +import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository'; +import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository'; +import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository'; +import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository'; +import { InMemoryProtestRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryProtestRepository'; +import { InMemoryTrackRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTrackRepository'; +import { InMemoryCarRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryCarRepository'; +import { + InMemoryGameRepository, + InMemorySeasonRepository, + InMemoryLeagueScoringConfigRepository, + getLeagueScoringPresetById, +} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories'; +import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider'; +import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository'; +import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository'; +import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository'; +import { InMemoryLeagueMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueMembershipRepository'; +import { + InMemoryFeedRepository, + InMemorySocialGraphRepository, +} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed'; +import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure'; + +// Application use cases and queries +import { + JoinLeagueUseCase, + RegisterForRaceUseCase, + WithdrawFromRaceUseCase, + IsDriverRegisteredForRaceQuery, + GetRaceRegistrationsQuery, + CreateTeamUseCase, + JoinTeamUseCase, + LeaveTeamUseCase, + ApproveTeamJoinRequestUseCase, + RejectTeamJoinRequestUseCase, + UpdateTeamUseCase, + GetAllTeamsQuery, + GetTeamDetailsQuery, + GetTeamMembersQuery, + GetTeamJoinRequestsQuery, + GetDriverTeamQuery, + GetLeagueStandingsQuery, + GetLeagueDriverSeasonStatsQuery, + GetAllLeaguesWithCapacityQuery, + GetAllLeaguesWithCapacityAndScoringQuery, + ListLeagueScoringPresetsQuery, + GetLeagueScoringConfigQuery, + CreateLeagueWithSeasonAndScoringUseCase, + GetLeagueFullConfigQuery, + GetRaceWithSOFQuery, + GetLeagueStatsQuery, + FileProtestUseCase, + ReviewProtestUseCase, + ApplyPenaltyUseCase, + GetRaceProtestsQuery, + GetRacePenaltiesQuery, + RequestProtestDefenseUseCase, + SubmitProtestDefenseUseCase, +} from '@gridpilot/racing/application'; +import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase'; +import type { DriverRatingProvider } from '@gridpilot/racing/application'; +import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; +import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application'; + +// Testing support +import { + createStaticRacingSeed, + getDemoLeagueArchetypeByName, + DEMO_TRACKS, + DEMO_CARS, + createDemoDriverStats, +} from '@gridpilot/testing-support'; + +/** + * Configure the DI container with all bindings for the website application + */ +export function configureDIContainer(): void { + // Clear any existing registrations + container.clearInstances(); + + // Create seed data + const seedData = createStaticRacingSeed(42); + const primaryDriverId = seedData.drivers[0]?.id ?? 'driver-1'; + + // Create driver statistics from seed data + const driverStats = createDemoDriverStats(seedData.drivers); + + // Register repositories + container.registerInstance( + DI_TOKENS.DriverRepository, + new InMemoryDriverRepository(seedData.drivers) + ); + + container.registerInstance( + DI_TOKENS.LeagueRepository, + new InMemoryLeagueRepository(seedData.leagues) + ); + + const raceRepository = new InMemoryRaceRepository(seedData.races); + container.registerInstance(DI_TOKENS.RaceRepository, raceRepository); + + // Result repository needs race repository for league-based queries + const resultRepository = new InMemoryResultRepository(seedData.results, raceRepository); + container.registerInstance(DI_TOKENS.ResultRepository, resultRepository); + + // Standing repository needs all three for recalculation + const leagueRepository = container.resolve(DI_TOKENS.LeagueRepository); + container.registerInstance( + DI_TOKENS.StandingRepository, + new InMemoryStandingRepository( + seedData.standings, + resultRepository, + raceRepository, + leagueRepository + ) + ); + + // Race registrations - seed from results for completed races, plus some upcoming races + const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = []; + + // For completed races, extract driver registrations from results + for (const result of seedData.results) { + seedRaceRegistrations.push({ + raceId: result.raceId, + driverId: result.driverId, + registeredAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }); + } + + // For some upcoming races, add random registrations + const upcomingRaces = seedData.races.filter(r => r.status === 'scheduled').slice(0, 10); + for (const race of upcomingRaces) { + const participantCount = Math.floor(Math.random() * 12) + 8; + const shuffledDrivers = [...seedData.drivers].sort(() => Math.random() - 0.5).slice(0, participantCount); + for (const driver of shuffledDrivers) { + seedRaceRegistrations.push({ + raceId: race.id, + driverId: driver.id, + registeredAt: new Date(Date.now() - Math.floor(Math.random() * 5) * 24 * 60 * 60 * 1000), + }); + } + } + + container.registerInstance( + DI_TOKENS.RaceRegistrationRepository, + new InMemoryRaceRegistrationRepository(seedRaceRegistrations) + ); + + // Seed penalties and protests + const completedRaces = seedData.races.filter(r => r.status === 'completed'); + const racesByLeague = new Map(); + for (const race of completedRaces) { + const existing = racesByLeague.get(race.leagueId) || []; + existing.push(race); + racesByLeague.set(race.leagueId, existing); + } + + const racesForProtests: Array<{ race: typeof completedRaces[0]; leagueIndex: number }> = []; + let leagueIndex = 0; + for (const [, leagueRaces] of racesByLeague) { + const sorted = [...leagueRaces].sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); + for (const race of sorted.slice(0, 2)) { + racesForProtests.push({ race, leagueIndex }); + } + leagueIndex++; + } + + const seededPenalties: Penalty[] = []; + const seededProtests: Protest[] = []; + + racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => { + const raceResults = seedData.results.filter(r => r.raceId === race.id); + if (raceResults.length < 4) return; + + const protestCount = Math.min(2, raceResults.length - 2); + for (let i = 0; i < protestCount; i++) { + const protestingResult = raceResults[i + 2]; + const accusedResult = raceResults[i]; + + if (!protestingResult || !accusedResult) continue; + + const protestStatuses: Array<'pending' | 'under_review' | 'upheld' | 'dismissed'> = ['pending', 'under_review', 'upheld', 'dismissed']; + const status = protestStatuses[(raceIndex + i) % protestStatuses.length]; + + const protest = Protest.create({ + id: `protest-${race.id}-${i}`, + raceId: race.id, + protestingDriverId: protestingResult.driverId, + accusedDriverId: accusedResult.driverId, + incident: { + lap: 5 + i * 3, + description: i === 0 + ? 'Unsafe rejoining to the track after going off, causing contact' + : 'Aggressive defending, pushing competitor off track', + }, + comment: i === 0 + ? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.' + : 'Driver moved under braking multiple times, forcing me off the circuit.', + status, + filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000), + reviewedBy: status !== 'pending' ? primaryDriverId : undefined, + decisionNotes: status === 'upheld' + ? 'After reviewing the evidence, the accused driver is found at fault. Penalty applied.' + : status === 'dismissed' + ? 'No clear fault found. Racing incident.' + : undefined, + reviewedAt: status !== 'pending' ? new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000) : undefined, + }); + + seededProtests.push(protest); + + if (status === 'upheld') { + const penaltyType = i % 2 === 0 ? 'points_deduction' : 'time_penalty'; + + const penalty = Penalty.create({ + id: `penalty-${race.id}-${i}`, + raceId: race.id, + driverId: accusedResult.driverId, + type: penaltyType, + value: penaltyType === 'points_deduction' ? 3 : 5, + reason: protest.incident.description, + protestId: protest.id, + issuedBy: primaryDriverId, + status: 'applied', + issuedAt: new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000), + appliedAt: new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000), + }); + + seededPenalties.push(penalty); + } + } + + // Add direct penalties + if (raceResults.length > 5) { + if (raceIndex % 3 === 0) { + const penalizedResult = raceResults[4]; + if (penalizedResult) { + const penalty = Penalty.create({ + id: `penalty-direct-${race.id}`, + raceId: race.id, + driverId: penalizedResult.driverId, + type: 'points_deduction', + value: 5, + reason: 'Causing avoidable collision', + issuedBy: primaryDriverId, + status: 'applied', + issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + }); + + seededPenalties.push(penalty); + } + } + + if (raceIndex % 3 === 1 && raceResults.length > 6) { + const penalizedResult = raceResults[5]; + if (penalizedResult) { + const penalty = Penalty.create({ + id: `penalty-direct-2-${race.id}`, + raceId: race.id, + driverId: penalizedResult.driverId, + type: 'points_deduction', + value: 2, + reason: 'Track limits violation - gained lasting advantage', + issuedBy: primaryDriverId, + status: 'applied', + issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + }); + + seededPenalties.push(penalty); + } + } + } + }); + + container.registerInstance( + DI_TOKENS.PenaltyRepository, + new InMemoryPenaltyRepository(seededPenalties) + ); + + container.registerInstance( + DI_TOKENS.ProtestRepository, + new InMemoryProtestRepository(seededProtests) + ); + + // Scoring repositories + const leagueScoringPresetProvider = new InMemoryLeagueScoringPresetProvider(); + container.registerInstance( + DI_TOKENS.LeagueScoringPresetProvider, + leagueScoringPresetProvider + ); + + const game = Game.create({ id: 'iracing', name: 'iRacing' }); + const seededSeasons: Season[] = []; + const seededScoringConfigs = []; + + for (const league of seedData.leagues) { + const archetype = getDemoLeagueArchetypeByName(league.name); + + const season = Season.create({ + id: `season-${league.id}-demo`, + leagueId: league.id, + gameId: game.id, + name: `${league.name} Demo Season`, + year: new Date().getFullYear(), + order: 1, + status: 'active', + startDate: new Date(), + endDate: new Date(), + }); + seededSeasons.push(season); + + const presetId = archetype?.scoringPresetId ?? 'club-default'; + const infraPreset = getLeagueScoringPresetById(presetId); + + if (infraPreset) { + const config = infraPreset.createConfig({ seasonId: season.id }); + seededScoringConfigs.push(config); + } + } + + container.registerInstance( + DI_TOKENS.GameRepository, + new InMemoryGameRepository([game]) + ); + + container.registerInstance( + DI_TOKENS.SeasonRepository, + new InMemorySeasonRepository(seededSeasons) + ); + + container.registerInstance( + DI_TOKENS.LeagueScoringConfigRepository, + new InMemoryLeagueScoringConfigRepository(seededScoringConfigs) + ); + + // League memberships + const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({ + leagueId: m.leagueId, + driverId: m.driverId, + role: 'member', + status: 'active', + joinedAt: new Date(), + })); + + // Ensure each league owner has an owner membership + for (const league of seedData.leagues) { + const existing = seededMemberships.find( + (m) => m.leagueId === league.id && m.driverId === league.ownerId, + ); + if (!existing) { + seededMemberships.push({ + leagueId: league.id, + driverId: league.ownerId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + } else { + existing.role = 'owner'; + } + } + + // Ensure primary driver owns at least one league + const hasPrimaryOwnerMembership = seededMemberships.some( + (m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner', + ); + if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) { + const targetLeague = + seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]; + + const existingForPrimary = seededMemberships.find( + (m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId, + ); + + if (existingForPrimary) { + existingForPrimary.role = 'owner'; + } else { + seededMemberships.push({ + leagueId: targetLeague.id, + driverId: primaryDriverId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + } + } + + // Add admins for primary league + const primaryLeagueForAdmins = + seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]; + + if (primaryLeagueForAdmins) { + const adminCandidates = seedData.drivers + .filter((d) => d.id !== primaryLeagueForAdmins.ownerId) + .slice(0, 2); + + adminCandidates.forEach((driver) => { + const existing = seededMemberships.find( + (m) => m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id, + ); + if (existing) { + if (existing.role !== 'owner') { + existing.role = 'admin'; + } + } else { + seededMemberships.push({ + leagueId: primaryLeagueForAdmins.id, + driverId: driver.id, + role: 'admin', + status: 'active', + joinedAt: new Date(), + }); + } + }); + } + + // Add stewards for primary league + if (primaryLeagueForAdmins) { + const stewardCandidates = seedData.drivers + .filter((d) => d.id !== primaryLeagueForAdmins.ownerId) + .slice(2, 5); + + stewardCandidates.forEach((driver) => { + const existing = seededMemberships.find( + (m) => m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id, + ); + if (existing) { + if (existing.role !== 'owner' && existing.role !== 'admin') { + existing.role = 'steward'; + } + } else { + seededMemberships.push({ + leagueId: primaryLeagueForAdmins.id, + driverId: driver.id, + role: 'steward', + status: 'active', + joinedAt: new Date(), + }); + } + }); + } + + // Seed pending join requests + const seededJoinRequests: JoinRequest[] = []; + const demoLeagues = seedData.leagues.slice(0, 6); + const extraDrivers = seedData.drivers.slice(5, 12); + + demoLeagues.forEach((league, leagueIndex) => { + const memberDriverIds = seededMemberships + .filter(m => m.leagueId === league.id) + .map(m => m.driverId); + + const availableDrivers = extraDrivers.filter(d => !memberDriverIds.includes(d.id)); + const driversForThisLeague = availableDrivers.slice(0, 3 + (leagueIndex % 3)); + + driversForThisLeague.forEach((driver, index) => { + const messages = [ + 'Would love to race in this series!', + 'Looking to join for the upcoming season.', + 'Heard great things about this league. Can I join?', + 'Experienced driver looking for competitive racing.', + 'My friend recommended this league. Hope to race with you!', + ]; + seededJoinRequests.push({ + id: `join-${league.id}-${driver.id}`, + leagueId: league.id, + driverId: driver.id, + requestedAt: new Date(Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000), + message: messages[(index + leagueIndex) % messages.length], + }); + }); + }); + + container.registerInstance( + DI_TOKENS.LeagueMembershipRepository, + new InMemoryLeagueMembershipRepository(seededMemberships, seededJoinRequests) + ); + + // Team repositories + container.registerInstance( + DI_TOKENS.TeamRepository, + new InMemoryTeamRepository( + seedData.teams.map((t) => ({ + id: t.id, + name: t.name, + tag: t.tag, + description: t.description, + ownerId: seedData.drivers[0]?.id ?? 'driver-1', + leagues: [t.primaryLeagueId], + createdAt: new Date(), + })) + ) + ); + + container.registerInstance( + DI_TOKENS.TeamMembershipRepository, + new InMemoryTeamMembershipRepository( + seedData.memberships + .filter((m) => m.teamId) + .map((m) => ({ + teamId: m.teamId!, + driverId: m.driverId, + role: 'driver', + status: 'active', + joinedAt: new Date(), + })) + ) + ); + + // Track and Car repositories + container.registerInstance( + DI_TOKENS.TrackRepository, + new InMemoryTrackRepository(DEMO_TRACKS) + ); + + container.registerInstance( + DI_TOKENS.CarRepository, + new InMemoryCarRepository(DEMO_CARS) + ); + + // Social repositories + container.registerInstance( + DI_TOKENS.FeedRepository, + new InMemoryFeedRepository(seedData) + ); + + container.registerInstance( + DI_TOKENS.SocialRepository, + new InMemorySocialGraphRepository(seedData) + ); + + // Image service + container.registerInstance( + DI_TOKENS.ImageService, + new DemoImageServiceAdapter() + ); + + // Notification repositories + container.registerInstance( + DI_TOKENS.NotificationRepository, + new InMemoryNotificationRepository() + ); + + container.registerInstance( + DI_TOKENS.NotificationPreferenceRepository, + new InMemoryNotificationPreferenceRepository() + ); + + const notificationGatewayRegistry = new NotificationGatewayRegistry([ + new InAppNotificationAdapter(), + ]); + container.registerInstance( + DI_TOKENS.NotificationGatewayRegistry, + notificationGatewayRegistry + ); + + // Register driver stats for access by utility functions + container.registerInstance( + DI_TOKENS.DriverStats, + driverStats + ); + + // Driver Rating Provider + const driverRatingProvider: DriverRatingProvider = { + getRating: (driverId: string): number | null => { + const stats = driverStats[driverId]; + return stats?.rating ?? null; + }, + getRatings: (driverIds: string[]): Map => { + const result = new Map(); + for (const id of driverIds) { + const stats = driverStats[id]; + if (stats?.rating) { + result.set(id, stats.rating); + } + } + return result; + }, + }; + container.registerInstance( + DI_TOKENS.DriverRatingProvider, + driverRatingProvider + ); + + // Resolve dependencies for use cases + const driverRepository = container.resolve(DI_TOKENS.DriverRepository); + const raceRegistrationRepository = container.resolve(DI_TOKENS.RaceRegistrationRepository); + const leagueMembershipRepository = container.resolve(DI_TOKENS.LeagueMembershipRepository); + const standingRepository = container.resolve(DI_TOKENS.StandingRepository); + const penaltyRepository = container.resolve(DI_TOKENS.PenaltyRepository); + const protestRepository = container.resolve(DI_TOKENS.ProtestRepository); + const teamRepository = container.resolve(DI_TOKENS.TeamRepository); + const teamMembershipRepository = container.resolve(DI_TOKENS.TeamMembershipRepository); + const seasonRepository = container.resolve(DI_TOKENS.SeasonRepository); + const leagueScoringConfigRepository = container.resolve(DI_TOKENS.LeagueScoringConfigRepository); + const gameRepository = container.resolve(DI_TOKENS.GameRepository); + const notificationRepository = container.resolve(DI_TOKENS.NotificationRepository); + const notificationPreferenceRepository = container.resolve(DI_TOKENS.NotificationPreferenceRepository); + + // Register use cases - Racing + container.registerInstance( + DI_TOKENS.JoinLeagueUseCase, + new JoinLeagueUseCase(leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.RegisterForRaceUseCase, + new RegisterForRaceUseCase(raceRegistrationRepository, leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.WithdrawFromRaceUseCase, + new WithdrawFromRaceUseCase(raceRegistrationRepository) + ); + + container.registerInstance( + DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase, + new CreateLeagueWithSeasonAndScoringUseCase( + leagueRepository, + seasonRepository, + leagueScoringConfigRepository, + leagueScoringPresetProvider + ) + ); + + container.registerInstance( + DI_TOKENS.TransferLeagueOwnershipUseCase, + new TransferLeagueOwnershipUseCase(leagueRepository, leagueMembershipRepository) + ); + + // Register use cases - Teams + container.registerInstance( + DI_TOKENS.CreateTeamUseCase, + new CreateTeamUseCase(teamRepository, teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.JoinTeamUseCase, + new JoinTeamUseCase(teamRepository, teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.LeaveTeamUseCase, + new LeaveTeamUseCase(teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.ApproveTeamJoinRequestUseCase, + new ApproveTeamJoinRequestUseCase(teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.RejectTeamJoinRequestUseCase, + new RejectTeamJoinRequestUseCase(teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.UpdateTeamUseCase, + new UpdateTeamUseCase(teamRepository, teamMembershipRepository) + ); + + // Register use cases - Stewarding + container.registerInstance( + DI_TOKENS.FileProtestUseCase, + new FileProtestUseCase(protestRepository, raceRepository, leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.ReviewProtestUseCase, + new ReviewProtestUseCase(protestRepository, raceRepository, leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.ApplyPenaltyUseCase, + new ApplyPenaltyUseCase( + penaltyRepository, + protestRepository, + raceRepository, + leagueMembershipRepository + ) + ); + + container.registerInstance( + DI_TOKENS.RequestProtestDefenseUseCase, + new RequestProtestDefenseUseCase(protestRepository, raceRepository, leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.SubmitProtestDefenseUseCase, + new SubmitProtestDefenseUseCase(protestRepository) + ); + + // Register use cases - Notifications + container.registerInstance( + DI_TOKENS.SendNotificationUseCase, + new SendNotificationUseCase( + notificationRepository, + notificationPreferenceRepository, + notificationGatewayRegistry + ) + ); + + container.registerInstance( + DI_TOKENS.MarkNotificationReadUseCase, + new MarkNotificationReadUseCase(notificationRepository) + ); + + // Register queries - Racing + container.registerInstance( + DI_TOKENS.IsDriverRegisteredForRaceQuery, + new IsDriverRegisteredForRaceQuery(raceRegistrationRepository) + ); + + container.registerInstance( + DI_TOKENS.GetRaceRegistrationsQuery, + new GetRaceRegistrationsQuery(raceRegistrationRepository) + ); + + container.registerInstance( + DI_TOKENS.GetLeagueStandingsQuery, + new GetLeagueStandingsQuery(standingRepository) + ); + + container.registerInstance( + DI_TOKENS.GetLeagueDriverSeasonStatsQuery, + new GetLeagueDriverSeasonStatsQuery( + standingRepository, + resultRepository, + penaltyRepository, + raceRepository, + { + getRating: (driverId: string) => { + const stats = driverStats[driverId]; + if (!stats) { + return { rating: null, ratingChange: null }; + } + const baseline = 1500; + const delta = stats.rating - baseline; + return { + rating: stats.rating, + ratingChange: delta !== 0 ? delta : null, + }; + }, + } + ) + ); + + container.registerInstance( + DI_TOKENS.GetAllLeaguesWithCapacityQuery, + new GetAllLeaguesWithCapacityQuery(leagueRepository, leagueMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.GetAllLeaguesWithCapacityAndScoringQuery, + new GetAllLeaguesWithCapacityAndScoringQuery( + leagueRepository, + leagueMembershipRepository, + seasonRepository, + leagueScoringConfigRepository, + gameRepository, + leagueScoringPresetProvider + ) + ); + + container.registerInstance( + DI_TOKENS.ListLeagueScoringPresetsQuery, + new ListLeagueScoringPresetsQuery(leagueScoringPresetProvider) + ); + + container.registerInstance( + DI_TOKENS.GetLeagueScoringConfigQuery, + new GetLeagueScoringConfigQuery( + leagueRepository, + seasonRepository, + leagueScoringConfigRepository, + gameRepository, + leagueScoringPresetProvider + ) + ); + + container.registerInstance( + DI_TOKENS.GetLeagueFullConfigQuery, + new GetLeagueFullConfigQuery( + leagueRepository, + seasonRepository, + leagueScoringConfigRepository, + gameRepository + ) + ); + + container.registerInstance( + DI_TOKENS.PreviewLeagueScheduleQuery, + new PreviewLeagueScheduleQuery() + ); + + container.registerInstance( + DI_TOKENS.GetRaceWithSOFQuery, + new GetRaceWithSOFQuery( + raceRepository, + raceRegistrationRepository, + resultRepository, + driverRatingProvider + ) + ); + + container.registerInstance( + DI_TOKENS.GetLeagueStatsQuery, + new GetLeagueStatsQuery( + leagueRepository, + raceRepository, + resultRepository, + driverRatingProvider + ) + ); + + // Register queries - Teams + container.registerInstance( + DI_TOKENS.GetAllTeamsQuery, + new GetAllTeamsQuery(teamRepository) + ); + + container.registerInstance( + DI_TOKENS.GetTeamDetailsQuery, + new GetTeamDetailsQuery(teamRepository, teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.GetTeamMembersQuery, + new GetTeamMembersQuery(teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.GetTeamJoinRequestsQuery, + new GetTeamJoinRequestsQuery(teamMembershipRepository) + ); + + container.registerInstance( + DI_TOKENS.GetDriverTeamQuery, + new GetDriverTeamQuery(teamRepository, teamMembershipRepository) + ); + + // Register queries - Stewarding + container.registerInstance( + DI_TOKENS.GetRaceProtestsQuery, + new GetRaceProtestsQuery(protestRepository, driverRepository) + ); + + container.registerInstance( + DI_TOKENS.GetRacePenaltiesQuery, + new GetRacePenaltiesQuery(penaltyRepository, driverRepository) + ); + + // Register queries - Notifications + container.registerInstance( + DI_TOKENS.GetUnreadNotificationsQuery, + new GetUnreadNotificationsQuery(notificationRepository) + ); +} + +/** + * Reset the container (for testing) + */ +export function resetDIContainer(): void { + container.clearInstances(); +} + +/** + * Get the TSyringe container instance + */ +export function getDIContainer() { + return container; +} \ No newline at end of file diff --git a/apps/website/lib/di-container.ts b/apps/website/lib/di-container.ts index 2830e7d35..69487a73d 100644 --- a/apps/website/lib/di-container.ts +++ b/apps/website/lib/di-container.ts @@ -1,21 +1,11 @@ /** - * Dependency Injection Container + * Dependency Injection Container - TSyringe Facade * - * Initializes all in-memory repositories and provides accessor functions. - * Allows easy swapping to persistent repositories later. + * Provides backward-compatible API for accessing dependencies managed by TSyringe. */ -import { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; -import { Protest } from '@gridpilot/racing/domain/entities/Protest'; -import { Driver } from '@gridpilot/racing/domain/entities/Driver'; -import { League } from '@gridpilot/racing/domain/entities/League'; -import { Race } from '@gridpilot/racing/domain/entities/Race'; -import { Result } from '@gridpilot/racing/domain/entities/Result'; -import { Standing } from '@gridpilot/racing/domain/entities/Standing'; -import { Game } from '@gridpilot/racing/domain/entities/Game'; -import { Season } from '@gridpilot/racing/domain/entities/Season'; -import { Track } from '@gridpilot/racing/domain/entities/Track'; -import { Car } from '@gridpilot/racing/domain/entities/Car'; +import { configureDIContainer, getDIContainer } from './di-config'; +import { DI_TOKENS } from './di-tokens'; import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; @@ -42,39 +32,12 @@ import type { ImageServicePort } from '@gridpilot/media'; // Notifications package imports import type { INotificationRepository, INotificationPreferenceRepository } from '@gridpilot/notifications/application'; -import { +import type { SendNotificationUseCase, MarkNotificationReadUseCase, GetUnreadNotificationsQuery } from '@gridpilot/notifications/application'; -import { - InMemoryNotificationRepository, - InMemoryNotificationPreferenceRepository, - NotificationGatewayRegistry, - InAppNotificationAdapter, -} from '@gridpilot/notifications/infrastructure'; - -import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository'; -import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository'; -import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository'; -import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository'; -import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository'; -import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository'; -import { InMemoryProtestRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryProtestRepository'; -import { InMemoryTrackRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTrackRepository'; -import { InMemoryCarRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryCarRepository'; -import { - InMemoryGameRepository, - InMemorySeasonRepository, - InMemoryLeagueScoringConfigRepository, - getLeagueScoringPresetById, -} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories'; -import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider'; -import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository'; -import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository'; -import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository'; -import { InMemoryLeagueMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueMembershipRepository'; -import { +import type { JoinLeagueUseCase, RegisterForRaceUseCase, WithdrawFromRaceUseCase, @@ -109,958 +72,31 @@ import { RequestProtestDefenseUseCase, SubmitProtestDefenseUseCase, } from '@gridpilot/racing/application'; -import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase'; +import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase'; import type { DriverRatingProvider } from '@gridpilot/racing/application'; -import { - createStaticRacingSeed, - type RacingSeedData, - getDemoLeagueArchetypeByName, -} from '@gridpilot/testing-support'; -import type { - LeagueScheduleDTO, - LeagueSchedulePreviewDTO, -} from '@gridpilot/racing/application'; -import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application'; -import { - InMemoryFeedRepository, - InMemorySocialGraphRepository, -} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed'; -import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure'; +import type { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application'; import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; +import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support'; /** - * Seed data for development - */ -/** - * Driver statistics and ranking data - */ -export interface DriverStats { - driverId: string; - rating: number; - totalRaces: number; - wins: number; - podiums: number; - dnfs: number; - avgFinish: number; - bestFinish: number; - worstFinish: number; - consistency: number; - overallRank: number; - percentile: number; -} - -/** - * Mock driver stats with calculated rankings - */ -const driverStats: Record = {}; - -function createSeedData(): RacingSeedData { - const seed = createStaticRacingSeed(42); - const { drivers } = seed; - - drivers.forEach((driver, index) => { - const totalRaces = 40 + index * 5; - const wins = Math.max(0, Math.floor(totalRaces * 0.2) - index); - const podiums = Math.max(wins * 2, 0); - const dnfs = Math.max(0, Math.floor(index / 2)); - const rating = 1500 + index * 25; - - driverStats[driver.id] = { - driverId: driver.id, - rating, - totalRaces, - wins, - podiums, - dnfs, - avgFinish: 4, - bestFinish: 1, - worstFinish: 20, - consistency: 80, - overallRank: index + 1, - percentile: Math.max(0, 100 - index), - }; - }); - - return seed; -} - -/** - * DI Container class + * DI Container - TSyringe Facade + * Provides singleton access to TSyringe container with lazy initialization */ class DIContainer { private static instance: DIContainer; - - private _driverRepository: IDriverRepository; - private _leagueRepository: ILeagueRepository; - private _raceRepository: IRaceRepository; - private _resultRepository: IResultRepository; - private _standingRepository: IStandingRepository; - private _penaltyRepository: IPenaltyRepository; - private _protestRepository: IProtestRepository; - private _teamRepository: ITeamRepository; - private _teamMembershipRepository: ITeamMembershipRepository; - private _raceRegistrationRepository: IRaceRegistrationRepository; - private _leagueMembershipRepository: ILeagueMembershipRepository; - private _gameRepository: IGameRepository; - private _seasonRepository: ISeasonRepository; - private _leagueScoringConfigRepository: ILeagueScoringConfigRepository; - private _leagueScoringPresetProvider: LeagueScoringPresetProvider; - private _feedRepository: IFeedRepository; - private _socialRepository: ISocialGraphRepository; - private _imageService: ImageServicePort; - private _trackRepository: ITrackRepository; - private _carRepository: ICarRepository; - - // Notifications - private _notificationRepository: INotificationRepository; - private _notificationPreferenceRepository: INotificationPreferenceRepository; - private _notificationGatewayRegistry: NotificationGatewayRegistry; - private _sendNotificationUseCase: SendNotificationUseCase; - private _markNotificationReadUseCase: MarkNotificationReadUseCase; - private _getUnreadNotificationsQuery: GetUnreadNotificationsQuery; - - // Racing application use-cases / queries - private _joinLeagueUseCase: JoinLeagueUseCase; - private _registerForRaceUseCase: RegisterForRaceUseCase; - private _withdrawFromRaceUseCase: WithdrawFromRaceUseCase; - private _isDriverRegisteredForRaceQuery: IsDriverRegisteredForRaceQuery; - private _getRaceRegistrationsQuery: GetRaceRegistrationsQuery; - private _getLeagueStandingsQuery: GetLeagueStandingsQuery; - private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery; - private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery; - private _getAllLeaguesWithCapacityAndScoringQuery: GetAllLeaguesWithCapacityAndScoringQuery; - private _listLeagueScoringPresetsQuery: ListLeagueScoringPresetsQuery; - private _getLeagueScoringConfigQuery: GetLeagueScoringConfigQuery; - private _createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase; - private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery; - // Placeholder for future schedule preview wiring - private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery; - private _getRaceWithSOFQuery: GetRaceWithSOFQuery; - private _getLeagueStatsQuery: GetLeagueStatsQuery; - private _driverRatingProvider: DriverRatingProvider; - - private _fileProtestUseCase: FileProtestUseCase; - private _reviewProtestUseCase: ReviewProtestUseCase; - private _applyPenaltyUseCase: ApplyPenaltyUseCase; - private _getRaceProtestsQuery: GetRaceProtestsQuery; - private _getRacePenaltiesQuery: GetRacePenaltiesQuery; - private _requestProtestDefenseUseCase: RequestProtestDefenseUseCase; - private _submitProtestDefenseUseCase: SubmitProtestDefenseUseCase; - - private _createTeamUseCase: CreateTeamUseCase; - private _joinTeamUseCase: JoinTeamUseCase; - private _leaveTeamUseCase: LeaveTeamUseCase; - private _approveTeamJoinRequestUseCase: ApproveTeamJoinRequestUseCase; - private _rejectTeamJoinRequestUseCase: RejectTeamJoinRequestUseCase; - private _updateTeamUseCase: UpdateTeamUseCase; - private _getAllTeamsQuery: GetAllTeamsQuery; - private _getTeamDetailsQuery: GetTeamDetailsQuery; - private _getTeamMembersQuery: GetTeamMembersQuery; - private _getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery; - private _getDriverTeamQuery: GetDriverTeamQuery; - private _transferLeagueOwnershipUseCase: TransferLeagueOwnershipUseCase; + private initialized = false; private constructor() { - // Create seed data - const seedData = createSeedData(); - const primaryDriverId = seedData.drivers[0]?.id ?? 'driver-1'; + // Private constructor for singleton pattern + } - // Initialize repositories with seed data - this._driverRepository = new InMemoryDriverRepository(seedData.drivers); - this._leagueRepository = new InMemoryLeagueRepository(seedData.leagues); - this._raceRepository = new InMemoryRaceRepository(seedData.races); - - // Result repository needs race repository for league-based queries - this._resultRepository = new InMemoryResultRepository( - seedData.results, - this._raceRepository - ); - - // Standing repository needs all three for recalculation - this._standingRepository = new InMemoryStandingRepository( - seedData.standings, - this._resultRepository, - this._raceRepository, - this._leagueRepository - ); - - // Race registrations - seed from results for completed races, plus some upcoming races - const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = []; - - // For completed races, extract driver registrations from results - for (const result of seedData.results) { - seedRaceRegistrations.push({ - raceId: result.raceId, - driverId: result.driverId, - registeredAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago - }); - } - - // For some upcoming races, add random registrations - const upcomingRaces = seedData.races.filter(r => r.status === 'scheduled').slice(0, 10); - for (const race of upcomingRaces) { - const participantCount = Math.floor(Math.random() * 12) + 8; // 8-20 participants - const shuffledDrivers = [...seedData.drivers].sort(() => Math.random() - 0.5).slice(0, participantCount); - for (const driver of shuffledDrivers) { - seedRaceRegistrations.push({ - raceId: race.id, - driverId: driver.id, - registeredAt: new Date(Date.now() - Math.floor(Math.random() * 5) * 24 * 60 * 60 * 1000), - }); - } - } - - this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository(seedRaceRegistrations); - - // Seed sample penalties and protests for completed races across different leagues - // Group completed races by league, then take 1-2 from each league to ensure coverage - const completedRaces = seedData.races.filter(r => r.status === 'completed'); - const racesByLeague = new Map(); - for (const race of completedRaces) { - const existing = racesByLeague.get(race.leagueId) || []; - existing.push(race); - racesByLeague.set(race.leagueId, existing); - } - - // Get up to 2 races per league for protest seeding - const racesForProtests: Array<{ race: typeof completedRaces[0]; leagueIndex: number }> = []; - let leagueIndex = 0; - for (const [, leagueRaces] of racesByLeague) { - // Sort by scheduled date, take earliest 2 - const sorted = [...leagueRaces].sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); - for (const race of sorted.slice(0, 2)) { - racesForProtests.push({ race, leagueIndex }); - } - leagueIndex++; - } - - const seededPenalties: Penalty[] = []; - const seededProtests: Protest[] = []; - - racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => { - // Get results for this race to find drivers involved - const raceResults = seedData.results.filter(r => r.raceId === race.id); - if (raceResults.length < 4) return; - - // Create 1-2 protests per race - const protestCount = Math.min(2, raceResults.length - 2); - for (let i = 0; i < protestCount; i++) { - const protestingResult = raceResults[i + 2]; // Driver who finished 3rd or 4th - const accusedResult = raceResults[i]; // Driver who finished 1st or 2nd - - if (!protestingResult || !accusedResult) continue; - - const protestStatuses: Array<'pending' | 'under_review' | 'upheld' | 'dismissed'> = ['pending', 'under_review', 'upheld', 'dismissed']; - const status = protestStatuses[(raceIndex + i) % protestStatuses.length]; - - const protest = Protest.create({ - id: `protest-${race.id}-${i}`, - raceId: race.id, - protestingDriverId: protestingResult.driverId, - accusedDriverId: accusedResult.driverId, - incident: { - lap: 5 + i * 3, - description: i === 0 - ? 'Unsafe rejoining to the track after going off, causing contact' - : 'Aggressive defending, pushing competitor off track', - }, - comment: i === 0 - ? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.' - : 'Driver moved under braking multiple times, forcing me off the circuit.', - status, - filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000), - reviewedBy: status !== 'pending' ? primaryDriverId : undefined, - decisionNotes: status === 'upheld' - ? 'After reviewing the evidence, the accused driver is found at fault. Penalty applied.' - : status === 'dismissed' - ? 'No clear fault found. Racing incident.' - : undefined, - reviewedAt: status !== 'pending' ? new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000) : undefined, - }); - - seededProtests.push(protest); - - // If protest was upheld, create a penalty - if (status === 'upheld') { - // Alternate between points deduction and time penalties for visibility - const penaltyType = i % 2 === 0 ? 'points_deduction' : 'time_penalty'; - - const penalty = Penalty.create({ - id: `penalty-${race.id}-${i}`, - raceId: race.id, - driverId: accusedResult.driverId, - type: penaltyType, - value: penaltyType === 'points_deduction' ? 3 : 5, - reason: protest.incident.description, - protestId: protest.id, - issuedBy: primaryDriverId, - status: 'applied', - issuedAt: new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000), - appliedAt: new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000), - }); - - seededPenalties.push(penalty); - } - } - - // Add direct penalties (not from protest) for better visibility in standings - if (raceResults.length > 5) { - // Add a points deduction penalty for some drivers - if (raceIndex % 3 === 0) { - const penalizedResult = raceResults[4]; - if (penalizedResult) { - const penalty = Penalty.create({ - id: `penalty-direct-${race.id}`, - raceId: race.id, - driverId: penalizedResult.driverId, - type: 'points_deduction', - value: 5, - reason: 'Causing avoidable collision', - issuedBy: primaryDriverId, - status: 'applied', - issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - }); - - seededPenalties.push(penalty); - } - } - - // Add another points deduction for different driver - if (raceIndex % 3 === 1 && raceResults.length > 6) { - const penalizedResult = raceResults[5]; - if (penalizedResult) { - const penalty = Penalty.create({ - id: `penalty-direct-2-${race.id}`, - raceId: race.id, - driverId: penalizedResult.driverId, - type: 'points_deduction', - value: 2, - reason: 'Track limits violation - gained lasting advantage', - issuedBy: primaryDriverId, - status: 'applied', - issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - }); - - seededPenalties.push(penalty); - } - } - } - }); - - // Penalties and protests with seeded data - this._penaltyRepository = new InMemoryPenaltyRepository(seededPenalties); - this._protestRepository = new InMemoryProtestRepository(seededProtests); - - // Scoring preset provider and seeded game/season/scoring config repositories - this._leagueScoringPresetProvider = new InMemoryLeagueScoringPresetProvider(); - - const game = Game.create({ id: 'iracing', name: 'iRacing' }); - - const seededSeasons: Season[] = []; - const seededScoringConfigs = []; - - for (const league of seedData.leagues) { - const archetype = getDemoLeagueArchetypeByName(league.name); - - const season = Season.create({ - id: `season-${league.id}-demo`, - leagueId: league.id, - gameId: game.id, - name: `${league.name} Demo Season`, - year: new Date().getFullYear(), - order: 1, - status: 'active', - startDate: new Date(), - endDate: new Date(), - }); - seededSeasons.push(season); - - // Use archetype preset if available, otherwise fall back to default club preset - const presetId = archetype?.scoringPresetId ?? 'club-default'; - const infraPreset = getLeagueScoringPresetById(presetId); - - if (infraPreset) { - const config = infraPreset.createConfig({ seasonId: season.id }); - seededScoringConfigs.push(config); - } - } - - this._gameRepository = new InMemoryGameRepository([game]); - this._seasonRepository = new InMemorySeasonRepository(seededSeasons); - this._leagueScoringConfigRepository = - new InMemoryLeagueScoringConfigRepository(seededScoringConfigs); - - // League memberships seeded from static memberships with guaranteed owner roles - const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({ - leagueId: m.leagueId, - driverId: m.driverId, - role: 'member', - status: 'active', - joinedAt: new Date(), - })); - - // Ensure each league owner has an owner membership - for (const league of seedData.leagues) { - const existing = seededMemberships.find( - (m) => m.leagueId === league.id && m.driverId === league.ownerId, - ); - if (!existing) { - seededMemberships.push({ - leagueId: league.id, - driverId: league.ownerId, - role: 'owner', - status: 'active', - joinedAt: new Date(), - }); - } else { - existing.role = 'owner'; - } - } - - // Ensure the primary demo driver owns at least one league in memberships - const hasPrimaryOwnerMembership = seededMemberships.some( - (m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner', - ); - if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) { - const targetLeague = - seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? - seedData.leagues[0]; - - const existingForPrimary = seededMemberships.find( - (m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId, - ); - - if (existingForPrimary) { - existingForPrimary.role = 'owner'; - } else { - seededMemberships.push({ - leagueId: targetLeague.id, - driverId: primaryDriverId, - role: 'owner', - status: 'active', - joinedAt: new Date(), - }); - } - } - - // Seed sample league admins for the primary driver's league (alpha demo) - const primaryLeagueForAdmins = - seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? - seedData.leagues[0]; - - if (primaryLeagueForAdmins) { - const adminCandidates = seedData.drivers - .filter((d) => d.id !== primaryLeagueForAdmins.ownerId) - .slice(0, 2); - - adminCandidates.forEach((driver) => { - const existing = seededMemberships.find( - (m) => - m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id, - ); - if (existing) { - if (existing.role !== 'owner') { - existing.role = 'admin'; - } - } else { - seededMemberships.push({ - leagueId: primaryLeagueForAdmins.id, - driverId: driver.id, - role: 'admin', - status: 'active', - joinedAt: new Date(), - }); - } - }); - } - - // Seed sample league stewards for the primary driver's league (alpha demo) - if (primaryLeagueForAdmins) { - const stewardCandidates = seedData.drivers - .filter((d) => d.id !== primaryLeagueForAdmins.ownerId) - .slice(2, 5); - - stewardCandidates.forEach((driver) => { - const existing = seededMemberships.find( - (m) => - m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id, - ); - if (existing) { - if (existing.role !== 'owner' && existing.role !== 'admin') { - existing.role = 'steward'; - } - } else { - seededMemberships.push({ - leagueId: primaryLeagueForAdmins.id, - driverId: driver.id, - role: 'steward', - status: 'active', - joinedAt: new Date(), - }); - } - }); - } - - // Seed a few pending join requests for demo leagues (expanded to more leagues) - const seededJoinRequests: JoinRequest[] = []; - const demoLeagues = seedData.leagues.slice(0, 6); // Expanded from 2 to 6 leagues - const extraDrivers = seedData.drivers.slice(5, 12); // More drivers for requests - - demoLeagues.forEach((league, leagueIndex) => { - // Skip leagues where these drivers are already members - const memberDriverIds = seededMemberships - .filter(m => m.leagueId === league.id) - .map(m => m.driverId); - - const availableDrivers = extraDrivers.filter(d => !memberDriverIds.includes(d.id)); - const driversForThisLeague = availableDrivers.slice(0, 3 + (leagueIndex % 3)); // 3-5 requests per league - - driversForThisLeague.forEach((driver, index) => { - const messages = [ - 'Would love to race in this series!', - 'Looking to join for the upcoming season.', - 'Heard great things about this league. Can I join?', - 'Experienced driver looking for competitive racing.', - 'My friend recommended this league. Hope to race with you!', - ]; - seededJoinRequests.push({ - id: `join-${league.id}-${driver.id}`, - leagueId: league.id, - driverId: driver.id, - requestedAt: new Date(Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000), - message: messages[(index + leagueIndex) % messages.length], - }); - }); - }); - - this._leagueMembershipRepository = new InMemoryLeagueMembershipRepository( - seededMemberships, - seededJoinRequests, - ); - - // Team repositories seeded from static memberships/teams - this._teamRepository = new InMemoryTeamRepository( - seedData.teams.map((t) => ({ - id: t.id, - name: t.name, - tag: t.tag, - description: t.description, - ownerId: seedData.drivers[0]?.id ?? 'driver-1', - leagues: [t.primaryLeagueId], - createdAt: new Date(), - })), - ); - - this._teamMembershipRepository = new InMemoryTeamMembershipRepository( - seedData.memberships - .filter((m) => m.teamId) - .map((m) => ({ - teamId: m.teamId!, - driverId: m.driverId, - role: 'driver', - status: 'active', - joinedAt: new Date(), - })), - ); - - // Application-layer use-cases and queries wired with repositories - this._joinLeagueUseCase = new JoinLeagueUseCase(this._leagueMembershipRepository); - this._registerForRaceUseCase = new RegisterForRaceUseCase( - this._raceRegistrationRepository, - this._leagueMembershipRepository, - ); - this._withdrawFromRaceUseCase = new WithdrawFromRaceUseCase( - this._raceRegistrationRepository, - ); - this._isDriverRegisteredForRaceQuery = new IsDriverRegisteredForRaceQuery( - this._raceRegistrationRepository, - ); - this._getRaceRegistrationsQuery = new GetRaceRegistrationsQuery( - this._raceRegistrationRepository, - ); - this._getLeagueStandingsQuery = new GetLeagueStandingsQuery(this._standingRepository); - - this._getLeagueDriverSeasonStatsQuery = new GetLeagueDriverSeasonStatsQuery( - this._standingRepository, - this._resultRepository, - this._penaltyRepository, - this._raceRepository, - { - getRating: (driverId: string) => { - const stats = driverStats[driverId]; - if (!stats) { - return { rating: null, ratingChange: null }; - } - // For alpha we expose current rating and a mock delta - const baseline = 1500; - const delta = stats.rating - baseline; - return { - rating: stats.rating, - ratingChange: delta !== 0 ? delta : null, - }; - }, - }, - ); - - this._getAllLeaguesWithCapacityQuery = new GetAllLeaguesWithCapacityQuery( - this._leagueRepository, - this._leagueMembershipRepository, - ); - - this._getAllLeaguesWithCapacityAndScoringQuery = - new GetAllLeaguesWithCapacityAndScoringQuery( - this._leagueRepository, - this._leagueMembershipRepository, - this._seasonRepository, - this._leagueScoringConfigRepository, - this._gameRepository, - this._leagueScoringPresetProvider, - ); - - this._listLeagueScoringPresetsQuery = new ListLeagueScoringPresetsQuery( - this._leagueScoringPresetProvider, - ); - - this._getLeagueScoringConfigQuery = new GetLeagueScoringConfigQuery( - this._leagueRepository, - this._seasonRepository, - this._leagueScoringConfigRepository, - this._gameRepository, - this._leagueScoringPresetProvider, - ); - - this._getLeagueFullConfigQuery = new GetLeagueFullConfigQuery( - this._leagueRepository, - this._seasonRepository, - this._leagueScoringConfigRepository, - this._gameRepository, - ); - - this._createLeagueWithSeasonAndScoringUseCase = - new CreateLeagueWithSeasonAndScoringUseCase( - this._leagueRepository, - this._seasonRepository, - this._leagueScoringConfigRepository, - this._leagueScoringPresetProvider, - ); - - // Schedule preview query (used by league creation wizard step 3) - this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery(); - - // DriverRatingProvider adapter using driverStats - this._driverRatingProvider = { - getRating: (driverId: string): number | null => { - const stats = driverStats[driverId]; - return stats?.rating ?? null; - }, - getRatings: (driverIds: string[]): Map => { - const result = new Map(); - for (const id of driverIds) { - const stats = driverStats[id]; - if (stats?.rating) { - result.set(id, stats.rating); - } - } - return result; - }, - }; - - // SOF queries - this._getRaceWithSOFQuery = new GetRaceWithSOFQuery( - this._raceRepository, - this._raceRegistrationRepository, - this._resultRepository, - this._driverRatingProvider, - ); - - this._getLeagueStatsQuery = new GetLeagueStatsQuery( - this._leagueRepository, - this._raceRepository, - this._resultRepository, - this._driverRatingProvider, - ); - - this._createTeamUseCase = new CreateTeamUseCase( - this._teamRepository, - this._teamMembershipRepository, - ); - this._joinTeamUseCase = new JoinTeamUseCase( - this._teamRepository, - this._teamMembershipRepository, - ); - this._leaveTeamUseCase = new LeaveTeamUseCase(this._teamMembershipRepository); - this._approveTeamJoinRequestUseCase = new ApproveTeamJoinRequestUseCase( - this._teamMembershipRepository, - ); - this._rejectTeamJoinRequestUseCase = new RejectTeamJoinRequestUseCase( - this._teamMembershipRepository, - ); - this._updateTeamUseCase = new UpdateTeamUseCase( - this._teamRepository, - this._teamMembershipRepository, - ); - this._getAllTeamsQuery = new GetAllTeamsQuery(this._teamRepository); - this._getTeamDetailsQuery = new GetTeamDetailsQuery( - this._teamRepository, - this._teamMembershipRepository, - ); - this._getTeamMembersQuery = new GetTeamMembersQuery(this._teamMembershipRepository); - this._getTeamJoinRequestsQuery = new GetTeamJoinRequestsQuery( - this._teamMembershipRepository, - ); - this._getDriverTeamQuery = new GetDriverTeamQuery( - this._teamRepository, - this._teamMembershipRepository, - ); - - this._transferLeagueOwnershipUseCase = new TransferLeagueOwnershipUseCase( - this._leagueRepository, - this._leagueMembershipRepository, - ); - - // Stewarding use cases and queries - this._fileProtestUseCase = new FileProtestUseCase( - this._protestRepository, - this._raceRepository, - this._leagueMembershipRepository, - ); - this._reviewProtestUseCase = new ReviewProtestUseCase( - this._protestRepository, - this._raceRepository, - this._leagueMembershipRepository, - ); - this._applyPenaltyUseCase = new ApplyPenaltyUseCase( - this._penaltyRepository, - this._protestRepository, - this._raceRepository, - this._leagueMembershipRepository, - ); - this._getRaceProtestsQuery = new GetRaceProtestsQuery( - this._protestRepository, - this._driverRepository, - ); - this._getRacePenaltiesQuery = new GetRacePenaltiesQuery( - this._penaltyRepository, - this._driverRepository, - ); - this._requestProtestDefenseUseCase = new RequestProtestDefenseUseCase( - this._protestRepository, - this._raceRepository, - this._leagueMembershipRepository, - ); - this._submitProtestDefenseUseCase = new SubmitProtestDefenseUseCase( - this._protestRepository, - ); - - // Social and feed adapters backed by static seed - this._feedRepository = new InMemoryFeedRepository(seedData); - this._socialRepository = new InMemorySocialGraphRepository(seedData); - - // Image service backed by demo adapter - this._imageService = new DemoImageServiceAdapter(); - - // Seed Track and Car data for demo - const seedTracks = [ - Track.create({ - id: 'track-spa', - name: 'Spa-Francorchamps', - shortName: 'SPA', - country: 'Belgium', - category: 'road', - difficulty: 'advanced', - lengthKm: 7.004, - turns: 19, - imageUrl: '/images/tracks/spa.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-monza', - name: 'Autodromo Nazionale Monza', - shortName: 'MON', - country: 'Italy', - category: 'road', - difficulty: 'intermediate', - lengthKm: 5.793, - turns: 11, - imageUrl: '/images/tracks/monza.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-nurburgring', - name: 'Nürburgring Grand Prix', - shortName: 'NUR', - country: 'Germany', - category: 'road', - difficulty: 'advanced', - lengthKm: 5.148, - turns: 15, - imageUrl: '/images/tracks/nurburgring.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-silverstone', - name: 'Silverstone Circuit', - shortName: 'SIL', - country: 'United Kingdom', - category: 'road', - difficulty: 'intermediate', - lengthKm: 5.891, - turns: 18, - imageUrl: '/images/tracks/silverstone.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-suzuka', - name: 'Suzuka International Racing Course', - shortName: 'SUZ', - country: 'Japan', - category: 'road', - difficulty: 'expert', - lengthKm: 5.807, - turns: 18, - imageUrl: '/images/tracks/suzuka.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-daytona', - name: 'Daytona International Speedway', - shortName: 'DAY', - country: 'United States', - category: 'oval', - difficulty: 'intermediate', - lengthKm: 4.023, - turns: 4, - imageUrl: '/images/tracks/daytona.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-laguna', - name: 'WeatherTech Raceway Laguna Seca', - shortName: 'LAG', - country: 'United States', - category: 'road', - difficulty: 'advanced', - lengthKm: 3.602, - turns: 11, - imageUrl: '/images/tracks/laguna.jpg', - gameId: 'iracing', - }), - ]; - - const seedCars = [ - Car.create({ - id: 'car-porsche-992', - name: '911 GT3 R', - shortName: '992 GT3R', - manufacturer: 'Porsche', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 565, - weight: 1300, - gameId: 'iracing', - }), - Car.create({ - id: 'car-ferrari-296', - name: '296 GT3', - shortName: '296 GT3', - manufacturer: 'Ferrari', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 600, - weight: 1270, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mclaren-720s', - name: '720S GT3 Evo', - shortName: '720S', - manufacturer: 'McLaren', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 552, - weight: 1290, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mercedes-gt3', - name: 'AMG GT3 2020', - shortName: 'AMG GT3', - manufacturer: 'Mercedes', - carClass: 'gt', - license: 'B', - year: 2020, - horsepower: 550, - weight: 1285, - gameId: 'iracing', - }), - Car.create({ - id: 'car-lmp2', - name: 'Dallara P217 LMP2', - shortName: 'LMP2', - manufacturer: 'Dallara', - carClass: 'prototype', - license: 'A', - year: 2021, - horsepower: 600, - weight: 930, - gameId: 'iracing', - }), - Car.create({ - id: 'car-f4', - name: 'Formula 4', - shortName: 'F4', - manufacturer: 'Tatuus', - carClass: 'formula', - license: 'D', - year: 2022, - horsepower: 160, - weight: 570, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mx5', - name: 'MX-5 Cup', - shortName: 'MX5', - manufacturer: 'Mazda', - carClass: 'sports', - license: 'D', - year: 2023, - horsepower: 181, - weight: 1128, - gameId: 'iracing', - }), - ]; - - this._trackRepository = new InMemoryTrackRepository(seedTracks); - this._carRepository = new InMemoryCarRepository(seedCars); - - // Initialize notifications - this._notificationRepository = new InMemoryNotificationRepository(); - this._notificationPreferenceRepository = new InMemoryNotificationPreferenceRepository(); - - // Set up gateway registry with adapters - this._notificationGatewayRegistry = new NotificationGatewayRegistry([ - new InAppNotificationAdapter(), - // Future: DiscordNotificationAdapter, EmailNotificationAdapter - ]); - - // Notification use cases - this._sendNotificationUseCase = new SendNotificationUseCase( - this._notificationRepository, - this._notificationPreferenceRepository, - this._notificationGatewayRegistry, - ); - this._markNotificationReadUseCase = new MarkNotificationReadUseCase( - this._notificationRepository, - ); - this._getUnreadNotificationsQuery = new GetUnreadNotificationsQuery( - this._notificationRepository, - ); + /** + * Ensure TSyringe container is configured + */ + private ensureInitialized(): void { + if (this.initialized) return; + configureDIContainer(); + this.initialized = true; } /** @@ -1078,254 +114,315 @@ class DIContainer { */ static reset(): void { DIContainer.instance = new DIContainer(); + DIContainer.instance.initialized = false; } /** - * Repository getters + * Repository getters - resolve from TSyringe container */ get driverRepository(): IDriverRepository { - return this._driverRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.DriverRepository); } get leagueRepository(): ILeagueRepository { - return this._leagueRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.LeagueRepository); } get raceRepository(): IRaceRepository { - return this._raceRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.RaceRepository); } get resultRepository(): IResultRepository { - return this._resultRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ResultRepository); } get standingRepository(): IStandingRepository { - return this._standingRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.StandingRepository); } get penaltyRepository(): IPenaltyRepository { - return this._penaltyRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.PenaltyRepository); } get protestRepository(): IProtestRepository { - return this._protestRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ProtestRepository); } get raceRegistrationRepository(): IRaceRegistrationRepository { - return this._raceRegistrationRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.RaceRegistrationRepository); } get leagueMembershipRepository(): ILeagueMembershipRepository { - return this._leagueMembershipRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.LeagueMembershipRepository); } get gameRepository(): IGameRepository { - return this._gameRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GameRepository); } get seasonRepository(): ISeasonRepository { - return this._seasonRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.SeasonRepository); } get leagueScoringConfigRepository(): ILeagueScoringConfigRepository { - return this._leagueScoringConfigRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.LeagueScoringConfigRepository); } get leagueScoringPresetProvider(): LeagueScoringPresetProvider { - return this._leagueScoringPresetProvider; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.LeagueScoringPresetProvider); } get joinLeagueUseCase(): JoinLeagueUseCase { - return this._joinLeagueUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.JoinLeagueUseCase); } get registerForRaceUseCase(): RegisterForRaceUseCase { - return this._registerForRaceUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.RegisterForRaceUseCase); } get withdrawFromRaceUseCase(): WithdrawFromRaceUseCase { - return this._withdrawFromRaceUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.WithdrawFromRaceUseCase); } get isDriverRegisteredForRaceQuery(): IsDriverRegisteredForRaceQuery { - return this._isDriverRegisteredForRaceQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.IsDriverRegisteredForRaceQuery); } get getRaceRegistrationsQuery(): GetRaceRegistrationsQuery { - return this._getRaceRegistrationsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetRaceRegistrationsQuery); } get getLeagueStandingsQuery(): GetLeagueStandingsQuery { - return this._getLeagueStandingsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetLeagueStandingsQuery); } get getLeagueDriverSeasonStatsQuery(): GetLeagueDriverSeasonStatsQuery { - return this._getLeagueDriverSeasonStatsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetLeagueDriverSeasonStatsQuery); } get getAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQuery { - return this._getAllLeaguesWithCapacityQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityQuery); } get getAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery { - return this._getAllLeaguesWithCapacityAndScoringQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringQuery); } get listLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery { - return this._listLeagueScoringPresetsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ListLeagueScoringPresetsQuery); } get getLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery { - return this._getLeagueScoringConfigQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetLeagueScoringConfigQuery); } get getLeagueFullConfigQuery(): GetLeagueFullConfigQuery { - return this._getLeagueFullConfigQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetLeagueFullConfigQuery); } - // Placeholder accessor for schedule preview; API route/UI can call this later. get previewLeagueScheduleQuery(): PreviewLeagueScheduleQuery { - return this._previewLeagueScheduleQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.PreviewLeagueScheduleQuery); } get getRaceWithSOFQuery(): GetRaceWithSOFQuery { - return this._getRaceWithSOFQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetRaceWithSOFQuery); } get getLeagueStatsQuery(): GetLeagueStatsQuery { - return this._getLeagueStatsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetLeagueStatsQuery); } get driverRatingProvider(): DriverRatingProvider { - return this._driverRatingProvider; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.DriverRatingProvider); } get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase { - return this._createLeagueWithSeasonAndScoringUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase); } get createTeamUseCase(): CreateTeamUseCase { - return this._createTeamUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.CreateTeamUseCase); } get joinTeamUseCase(): JoinTeamUseCase { - return this._joinTeamUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.JoinTeamUseCase); } get leaveTeamUseCase(): LeaveTeamUseCase { - return this._leaveTeamUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.LeaveTeamUseCase); } get approveTeamJoinRequestUseCase(): ApproveTeamJoinRequestUseCase { - return this._approveTeamJoinRequestUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ApproveTeamJoinRequestUseCase); } get rejectTeamJoinRequestUseCase(): RejectTeamJoinRequestUseCase { - return this._rejectTeamJoinRequestUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.RejectTeamJoinRequestUseCase); } get updateTeamUseCase(): UpdateTeamUseCase { - return this._updateTeamUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.UpdateTeamUseCase); } get getAllTeamsQuery(): GetAllTeamsQuery { - return this._getAllTeamsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetAllTeamsQuery); } get getTeamDetailsQuery(): GetTeamDetailsQuery { - return this._getTeamDetailsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetTeamDetailsQuery); } get getTeamMembersQuery(): GetTeamMembersQuery { - return this._getTeamMembersQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetTeamMembersQuery); } get getTeamJoinRequestsQuery(): GetTeamJoinRequestsQuery { - return this._getTeamJoinRequestsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetTeamJoinRequestsQuery); } get getDriverTeamQuery(): GetDriverTeamQuery { - return this._getDriverTeamQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetDriverTeamQuery); } get teamRepository(): ITeamRepository { - return this._teamRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.TeamRepository); } get teamMembershipRepository(): ITeamMembershipRepository { - return this._teamMembershipRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.TeamMembershipRepository); } get feedRepository(): IFeedRepository { - return this._feedRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.FeedRepository); } get socialRepository(): ISocialGraphRepository { - return this._socialRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.SocialRepository); } get imageService(): ImageServicePort { - return this._imageService; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ImageService); } get trackRepository(): ITrackRepository { - return this._trackRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.TrackRepository); } get carRepository(): ICarRepository { - return this._carRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.CarRepository); } get notificationRepository(): INotificationRepository { - return this._notificationRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.NotificationRepository); } get notificationPreferenceRepository(): INotificationPreferenceRepository { - return this._notificationPreferenceRepository; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.NotificationPreferenceRepository); } get sendNotificationUseCase(): SendNotificationUseCase { - return this._sendNotificationUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.SendNotificationUseCase); } get markNotificationReadUseCase(): MarkNotificationReadUseCase { - return this._markNotificationReadUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.MarkNotificationReadUseCase); } get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery { - return this._getUnreadNotificationsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetUnreadNotificationsQuery); } get fileProtestUseCase(): FileProtestUseCase { - return this._fileProtestUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.FileProtestUseCase); } get reviewProtestUseCase(): ReviewProtestUseCase { - return this._reviewProtestUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ReviewProtestUseCase); } get applyPenaltyUseCase(): ApplyPenaltyUseCase { - return this._applyPenaltyUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.ApplyPenaltyUseCase); } get getRaceProtestsQuery(): GetRaceProtestsQuery { - return this._getRaceProtestsQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetRaceProtestsQuery); } get getRacePenaltiesQuery(): GetRacePenaltiesQuery { - return this._getRacePenaltiesQuery; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetRacePenaltiesQuery); } get requestProtestDefenseUseCase(): RequestProtestDefenseUseCase { - return this._requestProtestDefenseUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.RequestProtestDefenseUseCase); } get submitProtestDefenseUseCase(): SubmitProtestDefenseUseCase { - return this._submitProtestDefenseUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.SubmitProtestDefenseUseCase); } get transferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseCase { - return this._transferLeagueOwnershipUseCase; + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.TransferLeagueOwnershipUseCase); } } @@ -1568,37 +665,31 @@ export function resetContainer(): void { DIContainer.reset(); } +/** + * Export stats from testing-support for backward compatibility + */ +export type { DriverStats }; + /** * Get driver statistics and rankings + * These functions access the demo driver stats registered in the DI container */ export function getDriverStats(driverId: string): DriverStats | null { - return driverStats[driverId] || null; + const container = DIContainer.getInstance(); + // Ensure container is initialized + container['ensureInitialized'](); + const stats = getDIContainer().resolve>(DI_TOKENS.DriverStats); + return stats[driverId] || null; } /** * Get all driver rankings sorted by rating */ export function getAllDriverRankings(): DriverStats[] { - return Object.values(driverStats).sort((a, b) => b.rating - a.rating); + const container = DIContainer.getInstance(); + // Ensure container is initialized + container['ensureInitialized'](); + const stats = getDIContainer().resolve>(DI_TOKENS.DriverStats); + return Object.values(stats).sort((a, b) => b.rating - a.rating); } - -/** - * Get league-specific rankings for a driver - */ -export function getLeagueRankings(driverId: string, leagueId: string): { - rank: number; - totalDrivers: number; - percentile: number; -} { - // Mock league rankings (in production, calculate from actual league membership) - const mockLeagueRanks: Record> = { - 'league-1': { - 'driver-1': { rank: 1, totalDrivers: 12, percentile: 92 }, - 'driver-2': { rank: 2, totalDrivers: 12, percentile: 84 }, - 'driver-3': { rank: 4, totalDrivers: 12, percentile: 67 }, - 'driver-4': { rank: 5, totalDrivers: 12, percentile: 58 } - } - }; - - return mockLeagueRanks[leagueId]?.[driverId] || { rank: 0, totalDrivers: 0, percentile: 0 }; -} \ No newline at end of file +export { getDemoLeagueRankings as getLeagueRankings }; \ No newline at end of file diff --git a/apps/website/lib/di-tokens.ts b/apps/website/lib/di-tokens.ts new file mode 100644 index 000000000..1c48eb5ff --- /dev/null +++ b/apps/website/lib/di-tokens.ts @@ -0,0 +1,94 @@ +/** + * Dependency Injection tokens for TSyringe container (Website) + */ + +export const DI_TOKENS = { + // Repositories + DriverRepository: Symbol.for('IDriverRepository'), + LeagueRepository: Symbol.for('ILeagueRepository'), + RaceRepository: Symbol.for('IRaceRepository'), + ResultRepository: Symbol.for('IResultRepository'), + StandingRepository: Symbol.for('IStandingRepository'), + PenaltyRepository: Symbol.for('IPenaltyRepository'), + ProtestRepository: Symbol.for('IProtestRepository'), + TeamRepository: Symbol.for('ITeamRepository'), + TeamMembershipRepository: Symbol.for('ITeamMembershipRepository'), + RaceRegistrationRepository: Symbol.for('IRaceRegistrationRepository'), + LeagueMembershipRepository: Symbol.for('ILeagueMembershipRepository'), + GameRepository: Symbol.for('IGameRepository'), + SeasonRepository: Symbol.for('ISeasonRepository'), + LeagueScoringConfigRepository: Symbol.for('ILeagueScoringConfigRepository'), + TrackRepository: Symbol.for('ITrackRepository'), + CarRepository: Symbol.for('ICarRepository'), + FeedRepository: Symbol.for('IFeedRepository'), + SocialRepository: Symbol.for('ISocialGraphRepository'), + NotificationRepository: Symbol.for('INotificationRepository'), + NotificationPreferenceRepository: Symbol.for('INotificationPreferenceRepository'), + + // Providers + LeagueScoringPresetProvider: Symbol.for('LeagueScoringPresetProvider'), + DriverRatingProvider: Symbol.for('DriverRatingProvider'), + + // Services + ImageService: Symbol.for('ImageServicePort'), + NotificationGatewayRegistry: Symbol.for('NotificationGatewayRegistry'), + + // Use Cases - Racing + JoinLeagueUseCase: Symbol.for('JoinLeagueUseCase'), + RegisterForRaceUseCase: Symbol.for('RegisterForRaceUseCase'), + WithdrawFromRaceUseCase: Symbol.for('WithdrawFromRaceUseCase'), + CreateLeagueWithSeasonAndScoringUseCase: Symbol.for('CreateLeagueWithSeasonAndScoringUseCase'), + TransferLeagueOwnershipUseCase: Symbol.for('TransferLeagueOwnershipUseCase'), + + // Use Cases - Teams + CreateTeamUseCase: Symbol.for('CreateTeamUseCase'), + JoinTeamUseCase: Symbol.for('JoinTeamUseCase'), + LeaveTeamUseCase: Symbol.for('LeaveTeamUseCase'), + ApproveTeamJoinRequestUseCase: Symbol.for('ApproveTeamJoinRequestUseCase'), + RejectTeamJoinRequestUseCase: Symbol.for('RejectTeamJoinRequestUseCase'), + UpdateTeamUseCase: Symbol.for('UpdateTeamUseCase'), + + // Use Cases - Stewarding + FileProtestUseCase: Symbol.for('FileProtestUseCase'), + ReviewProtestUseCase: Symbol.for('ReviewProtestUseCase'), + ApplyPenaltyUseCase: Symbol.for('ApplyPenaltyUseCase'), + RequestProtestDefenseUseCase: Symbol.for('RequestProtestDefenseUseCase'), + SubmitProtestDefenseUseCase: Symbol.for('SubmitProtestDefenseUseCase'), + + // Use Cases - Notifications + SendNotificationUseCase: Symbol.for('SendNotificationUseCase'), + MarkNotificationReadUseCase: Symbol.for('MarkNotificationReadUseCase'), + + // Queries - Racing + IsDriverRegisteredForRaceQuery: Symbol.for('IsDriverRegisteredForRaceQuery'), + GetRaceRegistrationsQuery: Symbol.for('GetRaceRegistrationsQuery'), + GetLeagueStandingsQuery: Symbol.for('GetLeagueStandingsQuery'), + GetLeagueDriverSeasonStatsQuery: Symbol.for('GetLeagueDriverSeasonStatsQuery'), + GetAllLeaguesWithCapacityQuery: Symbol.for('GetAllLeaguesWithCapacityQuery'), + GetAllLeaguesWithCapacityAndScoringQuery: Symbol.for('GetAllLeaguesWithCapacityAndScoringQuery'), + ListLeagueScoringPresetsQuery: Symbol.for('ListLeagueScoringPresetsQuery'), + GetLeagueScoringConfigQuery: Symbol.for('GetLeagueScoringConfigQuery'), + GetLeagueFullConfigQuery: Symbol.for('GetLeagueFullConfigQuery'), + PreviewLeagueScheduleQuery: Symbol.for('PreviewLeagueScheduleQuery'), + GetRaceWithSOFQuery: Symbol.for('GetRaceWithSOFQuery'), + GetLeagueStatsQuery: Symbol.for('GetLeagueStatsQuery'), + + // Queries - Teams + GetAllTeamsQuery: Symbol.for('GetAllTeamsQuery'), + GetTeamDetailsQuery: Symbol.for('GetTeamDetailsQuery'), + GetTeamMembersQuery: Symbol.for('GetTeamMembersQuery'), + GetTeamJoinRequestsQuery: Symbol.for('GetTeamJoinRequestsQuery'), + GetDriverTeamQuery: Symbol.for('GetDriverTeamQuery'), + + // Queries - Stewarding + GetRaceProtestsQuery: Symbol.for('GetRaceProtestsQuery'), + GetRacePenaltiesQuery: Symbol.for('GetRacePenaltiesQuery'), + + // Queries - Notifications + GetUnreadNotificationsQuery: Symbol.for('GetUnreadNotificationsQuery'), + + // Data + DriverStats: Symbol.for('DriverStats'), +} as const; + +export type DITokens = typeof DI_TOKENS; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f8c6bfb5..45cebe482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "dependencies": { "@gridpilot/social": "file:packages/social", "playwright-extra": "^4.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2" + "puppeteer-extra-plugin-stealth": "^2.11.2", + "reflect-metadata": "^0.2.2", + "tsyringe": "^4.10.0" }, "devDependencies": { "@cucumber/cucumber": "^11.0.1", @@ -10392,7 +10394,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { @@ -12449,6 +12450,24 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9bed70086..e11c01f04 100644 --- a/package.json +++ b/package.json @@ -11,44 +11,44 @@ "apps/*" ], "scripts": { - "dev": "echo 'Development server placeholder - to be configured'", - "build": "echo 'Build all packages placeholder - to be configured'", - "test": "vitest run && vitest run --config vitest.e2e.config.ts && npm run smoke:website", - "test:unit": "vitest run tests/unit", - "test:integration": "vitest run tests/integration", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/", - "test:watch": "vitest watch", - "test:smoke": "vitest run --config vitest.smoke.config.ts", - "test:smoke:watch": "vitest watch --config vitest.smoke.config.ts", - "test:smoke:electron": "playwright test --config=playwright.smoke.config.ts", - "smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts", - "test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/", - "test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts", - "typecheck": "tsc --noEmit", - "test:types": "tsc --noEmit -p tsconfig.tests.json", - "companion:dev": "npm run dev --workspace=@gridpilot/companion", - "companion:build": "npm run build --workspace=@gridpilot/companion", - "companion:start": "npm run start --workspace=@gridpilot/companion", - "env:website:merge": "node scripts/merge-website-env.js", - "website:dev": "npm run env:website:merge && npm run dev --workspace=@gridpilot/website", - "website:build": "npm run env:website:merge && npm run build --workspace=@gridpilot/website", - "website:start": "npm run start --workspace=@gridpilot/website", - "website:lint": "npm run lint --workspace=@gridpilot/website", - "website:type-check": "npm run type-check --workspace=@gridpilot/website", - "website:clean": "npm run clean --workspace=@gridpilot/website", - "deploy:website:preview": "npx vercel deploy --cwd apps/website", - "deploy:website:prod": "npx vercel deploy --prod", - "deploy:website": "npm run deploy:website:prod", - "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug", - "docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d", - "docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down", - "generate-templates": "npx tsx scripts/generate-templates/index.ts", - "minify-fixtures": "npx tsx scripts/minify-fixtures.ts", - "minify-fixtures:force": "npx tsx scripts/minify-fixtures.ts --force", - "dom:process": "npx tsx scripts/dom-export/processWorkflows.ts", - "prepare": "husky install || true" - }, + "dev": "echo 'Development server placeholder - to be configured'", + "build": "echo 'Build all packages placeholder - to be configured'", + "test": "vitest run && vitest run --config vitest.e2e.config.ts && npm run smoke:website", + "test:unit": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/", + "test:watch": "vitest watch", + "test:smoke": "vitest run --config vitest.smoke.config.ts", + "test:smoke:watch": "vitest watch --config vitest.smoke.config.ts", + "test:smoke:electron": "playwright test --config=playwright.smoke.config.ts", + "smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts", + "test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/", + "test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts", + "typecheck": "tsc --noEmit", + "test:types": "tsc --noEmit -p tsconfig.tests.json", + "companion:dev": "npm run dev --workspace=@gridpilot/companion", + "companion:build": "npm run build --workspace=@gridpilot/companion", + "companion:start": "npm run start --workspace=@gridpilot/companion", + "env:website:merge": "node scripts/merge-website-env.js", + "website:dev": "npm run env:website:merge && npm run dev --workspace=@gridpilot/website", + "website:build": "npm run env:website:merge && npm run build --workspace=@gridpilot/website", + "website:start": "npm run start --workspace=@gridpilot/website", + "website:lint": "npm run lint --workspace=@gridpilot/website", + "website:type-check": "npm run type-check --workspace=@gridpilot/website", + "website:clean": "npm run clean --workspace=@gridpilot/website", + "deploy:website:preview": "npx vercel deploy --cwd apps/website", + "deploy:website:prod": "npx vercel deploy --prod", + "deploy:website": "npm run deploy:website:prod", + "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug", + "docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d", + "docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down", + "generate-templates": "npx tsx scripts/generate-templates/index.ts", + "minify-fixtures": "npx tsx scripts/minify-fixtures.ts", + "minify-fixtures:force": "npx tsx scripts/minify-fixtures.ts --force", + "dom:process": "npx tsx scripts/dom-export/processWorkflows.ts", + "prepare": "husky install || true" + }, "devDependencies": { "@cucumber/cucumber": "^11.0.1", "@playwright/test": "^1.57.0", @@ -70,8 +70,10 @@ "vitest": "^2.1.8" }, "dependencies": { + "@gridpilot/social": "file:packages/social", "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", - "@gridpilot/social": "file:packages/social" + "reflect-metadata": "^0.2.2", + "tsyringe": "^4.10.0" } } diff --git a/packages/testing-support/index.ts b/packages/testing-support/index.ts index 8ed515262..860d38311 100644 --- a/packages/testing-support/index.ts +++ b/packages/testing-support/index.ts @@ -1,3 +1,4 @@ export * from './src/faker/faker'; export * from './src/images/images'; -export * from './src/racing/StaticRacingSeed'; \ No newline at end of file +export * from './src/racing/StaticRacingSeed'; +export * from './src/racing/DemoData'; \ No newline at end of file diff --git a/packages/testing-support/src/racing/DemoData.ts b/packages/testing-support/src/racing/DemoData.ts new file mode 100644 index 000000000..16d9e6fa0 --- /dev/null +++ b/packages/testing-support/src/racing/DemoData.ts @@ -0,0 +1,253 @@ +import { Track } from '@gridpilot/racing/domain/entities/Track'; +import { Car } from '@gridpilot/racing/domain/entities/Car'; + +/** + * Driver statistics and ranking data + */ +export interface DriverStats { + driverId: string; + rating: number; + totalRaces: number; + wins: number; + podiums: number; + dnfs: number; + avgFinish: number; + bestFinish: number; + worstFinish: number; + consistency: number; + overallRank: number; + percentile: number; +} + +/** + * Demo track data for iRacing + */ +export const DEMO_TRACKS: Track[] = [ + Track.create({ + id: 'track-spa', + name: 'Spa-Francorchamps', + shortName: 'SPA', + country: 'Belgium', + category: 'road', + difficulty: 'advanced', + lengthKm: 7.004, + turns: 19, + imageUrl: '/images/tracks/spa.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-monza', + name: 'Autodromo Nazionale Monza', + shortName: 'MON', + country: 'Italy', + category: 'road', + difficulty: 'intermediate', + lengthKm: 5.793, + turns: 11, + imageUrl: '/images/tracks/monza.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-nurburgring', + name: 'Nürburgring Grand Prix', + shortName: 'NUR', + country: 'Germany', + category: 'road', + difficulty: 'advanced', + lengthKm: 5.148, + turns: 15, + imageUrl: '/images/tracks/nurburgring.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-silverstone', + name: 'Silverstone Circuit', + shortName: 'SIL', + country: 'United Kingdom', + category: 'road', + difficulty: 'intermediate', + lengthKm: 5.891, + turns: 18, + imageUrl: '/images/tracks/silverstone.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-suzuka', + name: 'Suzuka International Racing Course', + shortName: 'SUZ', + country: 'Japan', + category: 'road', + difficulty: 'expert', + lengthKm: 5.807, + turns: 18, + imageUrl: '/images/tracks/suzuka.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-daytona', + name: 'Daytona International Speedway', + shortName: 'DAY', + country: 'United States', + category: 'oval', + difficulty: 'intermediate', + lengthKm: 4.023, + turns: 4, + imageUrl: '/images/tracks/daytona.jpg', + gameId: 'iracing', + }), + Track.create({ + id: 'track-laguna', + name: 'WeatherTech Raceway Laguna Seca', + shortName: 'LAG', + country: 'United States', + category: 'road', + difficulty: 'advanced', + lengthKm: 3.602, + turns: 11, + imageUrl: '/images/tracks/laguna.jpg', + gameId: 'iracing', + }), +]; + +/** + * Demo car data for iRacing + */ +export const DEMO_CARS: Car[] = [ + Car.create({ + id: 'car-porsche-992', + name: '911 GT3 R', + shortName: '992 GT3R', + manufacturer: 'Porsche', + carClass: 'gt', + license: 'B', + year: 2023, + horsepower: 565, + weight: 1300, + gameId: 'iracing', + }), + Car.create({ + id: 'car-ferrari-296', + name: '296 GT3', + shortName: '296 GT3', + manufacturer: 'Ferrari', + carClass: 'gt', + license: 'B', + year: 2023, + horsepower: 600, + weight: 1270, + gameId: 'iracing', + }), + Car.create({ + id: 'car-mclaren-720s', + name: '720S GT3 Evo', + shortName: '720S', + manufacturer: 'McLaren', + carClass: 'gt', + license: 'B', + year: 2023, + horsepower: 552, + weight: 1290, + gameId: 'iracing', + }), + Car.create({ + id: 'car-mercedes-gt3', + name: 'AMG GT3 2020', + shortName: 'AMG GT3', + manufacturer: 'Mercedes', + carClass: 'gt', + license: 'B', + year: 2020, + horsepower: 550, + weight: 1285, + gameId: 'iracing', + }), + Car.create({ + id: 'car-lmp2', + name: 'Dallara P217 LMP2', + shortName: 'LMP2', + manufacturer: 'Dallara', + carClass: 'prototype', + license: 'A', + year: 2021, + horsepower: 600, + weight: 930, + gameId: 'iracing', + }), + Car.create({ + id: 'car-f4', + name: 'Formula 4', + shortName: 'F4', + manufacturer: 'Tatuus', + carClass: 'formula', + license: 'D', + year: 2022, + horsepower: 160, + weight: 570, + gameId: 'iracing', + }), + Car.create({ + id: 'car-mx5', + name: 'MX-5 Cup', + shortName: 'MX5', + manufacturer: 'Mazda', + carClass: 'sports', + license: 'D', + year: 2023, + horsepower: 181, + weight: 1128, + gameId: 'iracing', + }), +]; + +/** + * Create demo driver statistics based on seed data + */ +export function createDemoDriverStats(drivers: Array<{ id: string }>): Record { + const stats: Record = {}; + + drivers.forEach((driver, index) => { + const totalRaces = 40 + index * 5; + const wins = Math.max(0, Math.floor(totalRaces * 0.2) - index); + const podiums = Math.max(wins * 2, 0); + const dnfs = Math.max(0, Math.floor(index / 2)); + const rating = 1500 + index * 25; + + stats[driver.id] = { + driverId: driver.id, + rating, + totalRaces, + wins, + podiums, + dnfs, + avgFinish: 4, + bestFinish: 1, + worstFinish: 20, + consistency: 80, + overallRank: index + 1, + percentile: Math.max(0, 100 - index), + }; + }); + + return stats; +} + +/** + * Get league-specific rankings for a driver (demo implementation) + */ +export function getDemoLeagueRankings(driverId: string, leagueId: string): { + rank: number; + totalDrivers: number; + percentile: number; +} { + // Mock league rankings (in production, calculate from actual league membership) + const mockLeagueRanks: Record> = { + 'league-1': { + 'driver-1': { rank: 1, totalDrivers: 12, percentile: 92 }, + 'driver-2': { rank: 2, totalDrivers: 12, percentile: 84 }, + 'driver-3': { rank: 4, totalDrivers: 12, percentile: 67 }, + 'driver-4': { rank: 5, totalDrivers: 12, percentile: 58 }, + }, + }; + + return mockLeagueRanks[leagueId]?.[driverId] || { rank: 0, totalDrivers: 0, percentile: 0 }; +} \ No newline at end of file