refactor to adapters
This commit is contained in:
34
adapters/bootstrap/EnsureInitialData.ts
Normal file
34
adapters/bootstrap/EnsureInitialData.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { SignupWithEmailUseCase } from '../../core/identity/application/use-cases/SignupWithEmailUseCase';
|
||||
|
||||
/**
|
||||
* EnsureInitialData - Bootstrap script to ensure initial data exists.
|
||||
* Idempotent: Can be run multiple times without issues.
|
||||
* Calls core use cases to create initial admin user if not exists.
|
||||
*/
|
||||
export class EnsureInitialData {
|
||||
constructor(
|
||||
private readonly signupUseCase: SignupWithEmailUseCase,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Ensure initial admin user exists
|
||||
try {
|
||||
await this.signupUseCase.execute({
|
||||
email: 'admin@gridpilot.local',
|
||||
password: 'admin123',
|
||||
displayName: 'Admin',
|
||||
});
|
||||
// User created successfully
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'An account with this email already exists') {
|
||||
// User already exists, nothing to do
|
||||
return;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Future: Add more initial data creation here
|
||||
// e.g., create default league, config, etc.
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepo
|
||||
async findByUserId(userId: string): Promise<AvatarGenerationRequest[]> {
|
||||
this.logger.debug(`Finding avatar generation requests by user ID: ${userId}`);
|
||||
const results: AvatarGenerationRequest[] = [];
|
||||
for (const props of this.requests.values()) {
|
||||
for (const props of Array.from(this.requests.values())) {
|
||||
if (props.userId === userId) {
|
||||
results.push(AvatarGenerationRequest.reconstitute(props));
|
||||
}
|
||||
12
adapters/persistence/migrations/001_initial_schema.ts
Normal file
12
adapters/persistence/migrations/001_initial_schema.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Migration 001: Initial schema setup
|
||||
// This migration sets up the initial database schema for the application.
|
||||
// Idempotent: Can be run multiple times without issues.
|
||||
|
||||
export async function up(): Promise<void> {
|
||||
// For in-memory persistence, no schema changes are needed.
|
||||
// This migration is a placeholder for future database migrations.
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Rollback not implemented for in-memory persistence.
|
||||
}
|
||||
50
apps/api/src/application/analytics.module.ts
Normal file
50
apps/api/src/application/analytics.module.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const ILogger_TOKEN = 'ILogger_TOKEN';
|
||||
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
|
||||
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
|
||||
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
|
||||
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
|
||||
import { RecordPageViewUseCase } from '@gridpilot/analytics/application/use-cases/RecordPageViewUseCase';
|
||||
import { RecordEngagementUseCase } from '@gridpilot/analytics/application/use-cases/RecordEngagementUseCase';
|
||||
|
||||
import { InMemoryPageViewRepository } from '../../../../adapters/persistence/inmemory/analytics/InMemoryPageViewRepository';
|
||||
import { TypeOrmEngagementRepository } from '../../../../adapters/persistence/typeorm/analytics/TypeOrmEngagementRepository';
|
||||
import { ConsoleLogger } from '../../../../adapters/logging/ConsoleLogger';
|
||||
import { AnalyticsController } from '../../presentation/analytics.controller';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [
|
||||
{
|
||||
provide: ILogger_TOKEN,
|
||||
useClass: ConsoleLogger,
|
||||
},
|
||||
{
|
||||
provide: IPAGE_VIEW_REPO_TOKEN,
|
||||
useClass: InMemoryPageViewRepository,
|
||||
},
|
||||
{
|
||||
provide: IENGAGEMENT_REPO_TOKEN,
|
||||
useFactory: (dataSource: DataSource) => new TypeOrmEngagementRepository(dataSource.manager),
|
||||
inject: [getDataSourceToken()],
|
||||
},
|
||||
{
|
||||
provide: RecordPageViewUseCase,
|
||||
useFactory: (repo: IPageViewRepository, logger: ILogger) => new RecordPageViewUseCase(repo, logger),
|
||||
inject: [IPAGE_VIEW_REPO_TOKEN, ILogger_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: RecordEngagementUseCase,
|
||||
useFactory: (repo: IEngagementRepository, logger: ILogger) => new RecordEngagementUseCase(repo, logger),
|
||||
inject: [IENGAGEMENT_REPO_TOKEN, ILogger_TOKEN],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PageViewEntity } from '../analytics/typeorm-page-view.entity';
|
||||
import { AnalyticsSnapshotOrmEntity } from '../../../../../adapters/persistence/typeorm/analytics/AnalyticsSnapshotOrmEntity';
|
||||
import { EngagementOrmEntity } from '../../../../../adapters/persistence/typeorm/analytics/EngagementOrmEntity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -11,9 +12,10 @@ import { PageViewEntity } from '../analytics/typeorm-page-view.entity';
|
||||
username: process.env.DATABASE_USER || 'user',
|
||||
password: process.env.DATABASE_PASSWORD || 'password',
|
||||
database: process.env.DATABASE_NAME || 'gridpilot',
|
||||
entities: [PageViewEntity],
|
||||
entities: [AnalyticsSnapshotOrmEntity, EngagementOrmEntity],
|
||||
synchronize: true, // Use carefully in production
|
||||
}),
|
||||
],
|
||||
exports: [TypeOrmModule],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
|
||||
import { AnalyticsService } from '../application/analytics/analytics.service';
|
||||
import { RecordPageViewInput } from '../application/analytics/record-page-view.use-case';
|
||||
import { RecordEngagementInput, RecordEngagementOutput } from '../application/analytics/record-engagement.use-case';
|
||||
|
||||
import type { RecordPageViewInput, RecordPageViewOutput } from '@gridpilot/analytics/application/use-cases/RecordPageViewUseCase';
|
||||
import type { RecordEngagementInput, RecordEngagementOutput } from '@gridpilot/analytics/application/use-cases/RecordEngagementUseCase';
|
||||
import { RecordPageViewUseCase } from '@gridpilot/analytics/application/use-cases/RecordPageViewUseCase';
|
||||
import { RecordEngagementUseCase } from '@gridpilot/analytics/application/use-cases/RecordEngagementUseCase';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
constructor(
|
||||
private readonly recordPageViewUseCase: RecordPageViewUseCase,
|
||||
private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
||||
) {}
|
||||
|
||||
@Post('page-view')
|
||||
async recordPageView(
|
||||
@Body() input: RecordPageViewInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { pageViewId } = await this.analyticsService.recordPageView(input);
|
||||
res.status(HttpStatus.CREATED).json({ pageViewId });
|
||||
const output: RecordPageViewOutput = await this.recordPageViewUseCase.execute(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
}
|
||||
|
||||
@Post('engagement')
|
||||
@@ -22,7 +27,7 @@ export class AnalyticsController {
|
||||
@Body() input: RecordEngagementInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
|
||||
const output: RecordEngagementOutput = await this.recordEngagementUseCase.execute(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { AuthService, AuthSession, SignupParams, LoginParams } from './AuthService';
|
||||
import type { AuthCallbackCommandDTO } from '@gridpilot/identity/application/dto/AuthCallbackCommandDTO';
|
||||
import type { StartAuthCommandDTO } from '@gridpilot/identity/application/dto/StartAuthCommandDTO';
|
||||
import { StartAuthUseCase } from '@gridpilot/identity/application/use-cases/StartAuthUseCase';
|
||||
import { GetCurrentUserSessionUseCase } from '@gridpilot/identity/application/use-cases/GetCurrentUserSessionUseCase';
|
||||
import { HandleAuthCallbackUseCase } from '@gridpilot/identity/application/use-cases/HandleAuthCallbackUseCase';
|
||||
import { LogoutUseCase } from '@gridpilot/identity/application/use-cases/LogoutUseCase';
|
||||
import { SignupWithEmailUseCase } from '@gridpilot/identity/application/use-cases/SignupWithEmailUseCase';
|
||||
import { LoginWithEmailUseCase } from '@gridpilot/identity/application/use-cases/LoginWithEmailUseCase';
|
||||
import { CookieIdentitySessionAdapter } from '@gridpilot/identity/infrastructure/session/CookieIdentitySessionAdapter';
|
||||
import { IracingDemoIdentityProviderAdapter } from '@gridpilot/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter';
|
||||
import { InMemoryUserRepository } from '@gridpilot/identity/infrastructure/repositories/InMemoryUserRepository';
|
||||
import type { IUserRepository } from '@gridpilot/identity/domain/repositories/IUserRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
// Singleton user repository to persist across requests (in-memory demo)
|
||||
let userRepositoryInstance: IUserRepository | null = null;
|
||||
|
||||
function getUserRepository(logger: ILogger): IUserRepository {
|
||||
if (!userRepositoryInstance) {
|
||||
userRepositoryInstance = new InMemoryUserRepository(logger);
|
||||
}
|
||||
return userRepositoryInstance;
|
||||
}
|
||||
|
||||
export class InMemoryAuthService implements AuthService {
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getCurrentSession(): Promise<AuthSession | null> {
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new GetCurrentUserSessionUseCase(sessionPort);
|
||||
return useCase.execute();
|
||||
}
|
||||
|
||||
async signupWithEmail(params: SignupParams): Promise<AuthSession> {
|
||||
const userRepository = getUserRepository();
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new SignupWithEmailUseCase(userRepository, sessionPort);
|
||||
|
||||
const result = await useCase.execute({
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
displayName: params.displayName,
|
||||
});
|
||||
|
||||
return result.session;
|
||||
}
|
||||
|
||||
async loginWithEmail(params: LoginParams): Promise<AuthSession> {
|
||||
const userRepository = getUserRepository();
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new LoginWithEmailUseCase(userRepository, sessionPort);
|
||||
|
||||
return useCase.execute({
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
});
|
||||
}
|
||||
|
||||
async startIracingAuthRedirect(
|
||||
returnTo?: string,
|
||||
): Promise<{ redirectUrl: string; state: string }> {
|
||||
const provider = new IracingDemoIdentityProviderAdapter();
|
||||
const useCase = new StartAuthUseCase(provider);
|
||||
|
||||
const command: StartAuthCommandDTO = returnTo
|
||||
? {
|
||||
provider: 'IRACING_DEMO',
|
||||
returnTo,
|
||||
}
|
||||
: {
|
||||
provider: 'IRACING_DEMO',
|
||||
};
|
||||
|
||||
return useCase.execute(command);
|
||||
}
|
||||
|
||||
async loginWithIracingCallback(params: {
|
||||
code: string;
|
||||
state: string;
|
||||
returnTo?: string;
|
||||
}): Promise<AuthSession> {
|
||||
const provider = new IracingDemoIdentityProviderAdapter();
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
|
||||
|
||||
const command: AuthCallbackCommandDTO = params.returnTo
|
||||
? {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
returnTo: params.returnTo,
|
||||
}
|
||||
: {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
};
|
||||
|
||||
return useCase.execute(command);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new LogoutUseCase(sessionPort);
|
||||
await useCase.execute();
|
||||
}
|
||||
}
|
||||
@@ -37,33 +37,48 @@ import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repos
|
||||
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { ImageServicePort } from '@gridpilot/media';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { ConsoleLogger } from '@gridpilot/shared/logging/ConsoleLogger';
|
||||
import type { ILogger } from '@gridpilot/shared/logging';
|
||||
import { ConsoleLogger } from '../../../adapters/logging';
|
||||
import type { IPageViewRepository, IEngagementRepository } from '@gridpilot/analytics';
|
||||
import { InMemoryPageViewRepository, InMemoryEngagementRepository } from '@gridpilot/analytics/infrastructure/repositories';
|
||||
import { RecordPageViewUseCase, RecordEngagementUseCase } from '@gridpilot/analytics/application/use-cases';
|
||||
import type { AuthService } from './auth/AuthService';
|
||||
import { InMemoryAuthService } from './auth/InMemoryAuthService';
|
||||
import type { IUserRepository, StoredUser } from '@gridpilot/identity/domain/repositories/IUserRepository';
|
||||
import { InMemoryUserRepository } from '@gridpilot/identity/infrastructure/repositories/InMemoryUserRepository';
|
||||
import type { ISponsorAccountRepository, SponsorAccount } from '@gridpilot/identity/domain/repositories/ISponsorAccountRepository';
|
||||
import { InMemorySponsorAccountRepository } from '@gridpilot/identity/infrastructure/repositories/InMemorySponsorAccountRepository';
|
||||
import type { ILiveryRepository } from '@gridpilot/racing/domain/repositories/ILiveryRepository';
|
||||
import { InMemoryLiveryRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLiveryRepository';
|
||||
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
|
||||
import { InMemoryChampionshipStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
||||
import type { ILeagueWalletRepository } from '@gridpilot/racing/domain/repositories/ILeagueWalletRepository';
|
||||
import { InMemoryLeagueWalletRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueWalletRepository';
|
||||
import type { ITransactionRepository } from '@gridpilot/racing/domain/repositories/ITransactionRepository';
|
||||
import { InMemoryTransactionRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTransactionRepository';
|
||||
import type { ISessionRepository } from '@gridpilot/racing/domain/repositories/ISessionRepository';
|
||||
import { InMemorySessionRepository } from '@gridpilot/racing/infrastructure/repositories/InMemorySessionRepository';
|
||||
import type { IAchievementRepository } from '@gridpilot/identity/domain/repositories/IAchievementRepository';
|
||||
import { InMemoryAchievementRepository } from '@gridpilot/identity/infrastructure/repositories/InMemoryAchievementRepository';
|
||||
import type { IUserRatingRepository } from '@gridpilot/identity/domain/repositories/IUserRatingRepository';
|
||||
import { InMemoryUserRatingRepository } from '@gridpilot/identity/infrastructure/repositories/InMemoryUserRatingRepository';
|
||||
import { ConsoleLogger } from '../../../adapters/logging';
|
||||
|
||||
import { DI_TOKENS } from './di-tokens';
|
||||
// Identity authentication use cases and ports
|
||||
import { StartAuthUseCase } from '@gridpilot/identity/application/use-cases/StartAuthUseCase';
|
||||
import { GetCurrentUserSessionUseCase } from '@gridpilot/identity/application/use-cases/GetCurrentUserSessionUseCase';
|
||||
import { HandleAuthCallbackUseCase } from '@gridpilot/identity/application/use-cases/HandleAuthCallbackUseCase';
|
||||
import { LogoutUseCase } from '@gridpilot/identity/application/use-cases/LogoutUseCase';
|
||||
import { SignupWithEmailUseCase } from '@gridpilot/identity/application/use-cases/SignupWithEmailUseCase';
|
||||
import { LoginWithEmailUseCase } from '@gridpilot/identity/application/use-cases/LoginWithEmailUseCase';
|
||||
import type { IdentityProvider } from '@gridpilot/identity/application/ports/IdentityProvider';
|
||||
import type { SessionPort } from '@gridpilot/identity/application/ports/SessionPort';
|
||||
import { IracingDemoIdentityProviderAdapter } from '../../../testing/fakes/identity/IracingDemoIdentityProviderAdapter';
|
||||
import { CookieIdentitySessionAdapter } from '@gridpilot/identity/infrastructure/session/CookieIdentitySessionAdapter';
|
||||
|
||||
import type { AuthService } from './auth/AuthService';
|
||||
import type { ILogger } from '@gridpilot/shared/logging';
|
||||
|
||||
// Repositories
|
||||
import type { IAchievementRepository } from '@gridpilot/identity/domain/repositories/IAchievementRepository';
|
||||
import { InMemoryAchievementRepository } from '../../adapters/persistence/inmemory/identity/InMemoryAchievementRepository';
|
||||
import type { IUserRatingRepository } from '@gridpilot/identity/domain/repositories/IUserRatingRepository';
|
||||
import { InMemoryUserRatingRepository } from '../../adapters/persistence/inmemory/identity/InMemoryUserRatingRepository';
|
||||
import type { ISponsorAccountRepository, SponsorAccount } from '@gridpilot/identity/domain/repositories/ISponsorAccountRepository';
|
||||
import { InMemorySponsorAccountRepository } from '../../adapters/persistence/inmemory/identity/InMemorySponsorAccountRepository';
|
||||
import type { IUserRepository, StoredUser } from '@gridpilot/identity/domain/repositories/IUserRepository';
|
||||
import { InMemoryUserRepository } from '../../adapters/persistence/inmemory/identity/InMemoryUserRepository';
|
||||
|
||||
import type { ILiveryRepository } from '@gridpilot/racing/domain/repositories/ILiveryRepository';
|
||||
import { InMemoryLiveryRepository } from '../../adapters/persistence/inmemory/racing/InMemoryLiveryRepository';
|
||||
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
|
||||
import { InMemoryChampionshipStandingRepository } from '../../adapters/persistence/inmemory/racing/InMemoryScoringRepositories';
|
||||
import type { ILeagueWalletRepository } from '@gridpilot/racing/domain/repositories/ILeagueWalletRepository';
|
||||
import { InMemoryLeagueWalletRepository } from '../../adapters/persistence/inmemory/racing/InMemoryLeagueWalletRepository';
|
||||
import type { ITransactionRepository } from '@gridpilot/racing/domain/repositories/ITransactionRepository';
|
||||
import { InMemoryTransactionRepository } from '../../adapters/persistence/inmemory/racing/InMemoryTransactionRepository';
|
||||
import type { ISessionRepository } from '@gridpilot/racing/domain/repositories/ISessionRepository';
|
||||
import { InMemorySessionRepository } from '../../adapters/persistence/inmemory/racing/InMemorySessionRepository';
|
||||
|
||||
// Notifications
|
||||
import type { INotificationRepository, INotificationPreferenceRepository } from '@gridpilot/notifications/application';
|
||||
import {
|
||||
SendNotificationUseCase,
|
||||
@@ -77,10 +92,17 @@ import {
|
||||
InAppNotificationAdapter,
|
||||
} from '@gridpilot/notifications/infrastructure';
|
||||
|
||||
import type { IPageViewRepository, IEngagementRepository } from '@gridpilot/analytics';
|
||||
import { InMemoryPageViewRepository, InMemoryEngagementRepository } from '../../adapters/persistence/inmemory/analytics/InMemoryAnalyticsRepositories';
|
||||
import {
|
||||
RecordPageViewUseCase,
|
||||
RecordEngagementUseCase
|
||||
} from '@gridpilot/analytics/application/use-cases';
|
||||
|
||||
// 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 { InMemoryDriverRepository } from '../../adapters/persistence/inmemory/racing/InMemoryDriverRepository';
|
||||
import { InMemoryLeagueRepository } from '../../adapters/persistence/inmemory/racing/InMemoryLeagueRepository';
|
||||
import { InMemoryRaceRepository } from '../../adapters/persistence/inmemory/racing/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';
|
||||
@@ -180,6 +202,7 @@ import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
|
||||
import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter';
|
||||
import { LeagueFullConfigPresenter } from './presenters/LeagueFullConfigPresenter';
|
||||
import { LeagueDriverSeasonStatsPresenter } from './presenters/LeagueDriverSeasonStatsPresenter';
|
||||
import { LeagueStandingsPresenter as ILeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
|
||||
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
|
||||
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
||||
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
|
||||
@@ -245,6 +268,10 @@ export function configureDIContainer(): void {
|
||||
const driverStats: DemoDriverStatsMap = createDemoDriverStats(seedData.drivers);
|
||||
|
||||
// Register repositories
|
||||
container.registerInstance<ILeagueStandingsRepository>(
|
||||
DI_TOKENS.LeagueStandingsRepository,
|
||||
new LeagueStandingsRepositoryAdapter()
|
||||
);
|
||||
container.registerInstance<IDriverRepository>(
|
||||
DI_TOKENS.DriverRepository,
|
||||
new InMemoryDriverRepository(logger, seedData.drivers)
|
||||
@@ -920,6 +947,7 @@ export function configureDIContainer(): void {
|
||||
const raceRegistrationRepository = container.resolve<IRaceRegistrationRepository>(DI_TOKENS.RaceRegistrationRepository);
|
||||
const leagueMembershipRepository = container.resolve<ILeagueMembershipRepository>(DI_TOKENS.LeagueMembershipRepository);
|
||||
const standingRepository = container.resolve<IStandingRepository>(DI_TOKENS.StandingRepository);
|
||||
const leagueStandingsRepository = container.resolve<ILeagueStandingsRepository>(DI_TOKENS.LeagueStandingsRepository);
|
||||
const penaltyRepository = container.resolve<IPenaltyRepository>(DI_TOKENS.PenaltyRepository);
|
||||
const protestRepository = container.resolve<IProtestRepository>(DI_TOKENS.ProtestRepository);
|
||||
const teamRepository = container.resolve<ITeamRepository>(DI_TOKENS.TeamRepository);
|
||||
@@ -1095,10 +1123,14 @@ export function configureDIContainer(): void {
|
||||
new GetRaceRegistrationsUseCase(raceRegistrationRepository)
|
||||
);
|
||||
|
||||
const leagueStandingsPresenter = new LeagueStandingsPresenter();
|
||||
container.registerInstance(
|
||||
container.registerInstance<IGetLeagueStandingsUseCase>(
|
||||
DI_TOKENS.GetLeagueStandingsUseCase,
|
||||
new GetLeagueStandingsUseCase(standingRepository),
|
||||
new GetLeagueStandingsUseCaseImpl(leagueStandingsRepository),
|
||||
);
|
||||
|
||||
container.registerInstance<ILeagueStandingsPresenter>(
|
||||
DI_TOKENS.LeagueStandingsPresenter,
|
||||
new LeagueStandingsPresenter(container.resolve<IGetLeagueStandingsUseCase>(DI_TOKENS.GetLeagueStandingsUseCase)),
|
||||
);
|
||||
|
||||
container.registerInstance(
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DI_TOKENS = {
|
||||
RaceRepository: Symbol.for('IRaceRepository'),
|
||||
ResultRepository: Symbol.for('IResultRepository'),
|
||||
StandingRepository: Symbol.for('IStandingRepository'),
|
||||
LeagueStandingsRepository: Symbol.for('ILeagueStandingsRepository'),
|
||||
PenaltyRepository: Symbol.for('IPenaltyRepository'),
|
||||
ProtestRepository: Symbol.for('IProtestRepository'),
|
||||
TeamRepository: Symbol.for('ITeamRepository'),
|
||||
@@ -50,6 +51,18 @@ export const DI_TOKENS = {
|
||||
Logger: Symbol.for('ILogger'),
|
||||
AuthService: Symbol.for('AuthService'),
|
||||
|
||||
// Auth dependencies
|
||||
IdentityProvider: Symbol.for('IdentityProvider'),
|
||||
SessionPort: Symbol.for('SessionPort'),
|
||||
|
||||
// Use Cases - Auth
|
||||
StartAuthUseCase: Symbol.for('StartAuthUseCase'),
|
||||
GetCurrentUserSessionUseCase: Symbol.for('GetCurrentUserSessionUseCase'),
|
||||
HandleAuthCallbackUseCase: Symbol.for('HandleAuthCallbackUseCase'),
|
||||
LogoutUseCase: Symbol.for('LogoutUseCase'),
|
||||
SignupWithEmailUseCase: Symbol.for('SignupWithEmailUseCase'),
|
||||
LoginWithEmailUseCase: Symbol.for('LoginWithEmailUseCase'),
|
||||
|
||||
// Use Cases - Analytics
|
||||
RecordPageViewUseCase: Symbol.for('RecordPageViewUseCase'),
|
||||
RecordEngagementUseCase: Symbol.for('RecordEngagementUseCase'),
|
||||
@@ -141,6 +154,7 @@ export const DI_TOKENS = {
|
||||
DriverStats: Symbol.for('DriverStats'),
|
||||
|
||||
// Presenters - Racing
|
||||
LeagueStandingsPresenter: Symbol.for('ILeagueStandingsPresenter'),
|
||||
RaceWithSOFPresenter: Symbol.for('IRaceWithSOFPresenter'),
|
||||
RaceProtestsPresenter: Symbol.for('IRaceProtestsPresenter'),
|
||||
RacePenaltiesPresenter: Symbol.for('IRacePenaltiesPresenter'),
|
||||
|
||||
@@ -1,41 +1,22 @@
|
||||
import type {
|
||||
ILeagueStandingsPresenter,
|
||||
LeagueStandingsResultDTO,
|
||||
LeagueStandingsViewModel,
|
||||
StandingItemViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter';
|
||||
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel } from '@gridpilot/core/league/application/use-cases/GetLeagueStandingsUseCase';
|
||||
|
||||
export interface ILeagueStandingsPresenter {
|
||||
present(leagueId: string): Promise<void>;
|
||||
getViewModel(): LeagueStandingsViewModel | null;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
||||
private viewModel: LeagueStandingsViewModel | null = null;
|
||||
|
||||
constructor(private getLeagueStandingsUseCase: GetLeagueStandingsUseCase) {}
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(dto: LeagueStandingsResultDTO): void {
|
||||
const standingItems: StandingItemViewModel[] = dto.standings.map((standing) => {
|
||||
const raw = standing as unknown as {
|
||||
seasonId?: string;
|
||||
podiums?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
id: standing.id,
|
||||
leagueId: standing.leagueId,
|
||||
seasonId: raw.seasonId ?? '',
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
podiums: raw.podiums ?? 0,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
leagueId: dto.standings[0]?.leagueId ?? '',
|
||||
standings: standingItems,
|
||||
};
|
||||
async present(leagueId: string): Promise<void> {
|
||||
this.viewModel = await this.getLeagueStandingsUseCase.execute(leagueId);
|
||||
}
|
||||
|
||||
getViewModel(): LeagueStandingsViewModel | null {
|
||||
|
||||
@@ -28,13 +28,9 @@
|
||||
"@gridpilot/testing-support/*": ["../../core/testing-support/*"],
|
||||
"@gridpilot/media": ["../../core/media/index.ts"],
|
||||
"@gridpilot/media/*": ["../../core/media/*"],
|
||||
"@gridpilot/shared": ["../../core/shared/index.ts"],
|
||||
"@gridpilot/shared/application": ["../../core/shared/application"],
|
||||
"@gridpilot/shared/application/*": ["../../core/shared/application/*"],
|
||||
"@gridpilot/shared/presentation": ["../../core/shared/presentation"],
|
||||
"@gridpilot/shared/presentation/*": ["../../core/shared/presentation/*"],
|
||||
"@gridpilot/shared/domain": ["../../core/shared/domain"],
|
||||
"@gridpilot/shared/errors": ["../../core/shared/errors"]
|
||||
"@gridpilot/shared/logging": ["../../core/shared/logging"],
|
||||
"@gridpilot/shared/*": ["../../core/shared/*"],
|
||||
"@gridpilot/core/*": ["../../core/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
||||
14
core/identity/application/use-cases/GetUserUseCase.ts
Normal file
14
core/identity/application/use-cases/GetUserUseCase.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
|
||||
export class GetUserUseCase {
|
||||
constructor(private userRepo: IUserRepository) {}
|
||||
|
||||
async execute(userId: string): Promise<User> {
|
||||
const stored = await this.userRepo.findById(userId);
|
||||
if (!stored) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return User.fromStored(stored);
|
||||
}
|
||||
}
|
||||
29
core/identity/application/use-cases/LoginUseCase.ts
Normal file
29
core/identity/application/use-cases/LoginUseCase.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
|
||||
/**
|
||||
* Application Use Case: LoginUseCase
|
||||
*
|
||||
* Handles user login by verifying credentials.
|
||||
*/
|
||||
export class LoginUseCase {
|
||||
constructor(
|
||||
private authRepo: IAuthRepository,
|
||||
private passwordService: IPasswordHashingService
|
||||
) {}
|
||||
|
||||
async execute(email: string, password: string): Promise<User> {
|
||||
const emailVO = EmailAddress.create(email);
|
||||
const user = await this.authRepo.findByEmail(emailVO);
|
||||
if (!user || !user.getPasswordHash()) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
const isValid = await this.passwordService.verify(password, user.getPasswordHash()!.value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
41
core/identity/application/use-cases/SignupUseCase.ts
Normal file
41
core/identity/application/use-cases/SignupUseCase.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
|
||||
/**
|
||||
* Application Use Case: SignupUseCase
|
||||
*
|
||||
* Handles user registration.
|
||||
*/
|
||||
export class SignupUseCase {
|
||||
constructor(
|
||||
private authRepo: IAuthRepository,
|
||||
private passwordService: IPasswordHashingService
|
||||
) {}
|
||||
|
||||
async execute(email: string, password: string, displayName: string): Promise<User> {
|
||||
const emailVO = EmailAddress.create(email);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await this.authRepo.findByEmail(emailVO);
|
||||
if (existingUser) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const hashedPassword = await this.passwordService.hash(password);
|
||||
const passwordHash = await import('../../domain/value-objects/PasswordHash').then(m => m.PasswordHash.fromHash(hashedPassword));
|
||||
|
||||
const userId = UserId.create();
|
||||
const user = User.create({
|
||||
id: userId,
|
||||
displayName,
|
||||
email: emailVO.value,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
await this.authRepo.save(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { EmailValidationResult } from '../types/EmailAddress';
|
||||
import { validateEmail } from '../types/EmailAddress';
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import { PasswordHash } from '../value-objects/PasswordHash';
|
||||
import { StoredUser } from '../repositories/IUserRepository';
|
||||
|
||||
export interface UserProps {
|
||||
id: UserId;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
passwordHash?: PasswordHash;
|
||||
iracingCustomerId?: string;
|
||||
primaryDriverId?: string;
|
||||
avatarUrl?: string;
|
||||
@@ -15,6 +18,7 @@ export class User {
|
||||
private readonly id: UserId;
|
||||
private displayName: string;
|
||||
private email: string | undefined;
|
||||
private passwordHash: PasswordHash | undefined;
|
||||
private iracingCustomerId: string | undefined;
|
||||
private primaryDriverId: string | undefined;
|
||||
private avatarUrl: string | undefined;
|
||||
@@ -47,6 +51,22 @@ export class User {
|
||||
return new User(props);
|
||||
}
|
||||
|
||||
public static fromStored(stored: StoredUser): User {
|
||||
const passwordHash = stored.passwordHash ? PasswordHash.fromHash(stored.passwordHash) : undefined;
|
||||
const userProps: any = {
|
||||
id: UserId.fromString(stored.id),
|
||||
displayName: stored.displayName,
|
||||
email: stored.email,
|
||||
};
|
||||
if (passwordHash) {
|
||||
userProps.passwordHash = passwordHash;
|
||||
}
|
||||
if (stored.primaryDriverId) {
|
||||
userProps.primaryDriverId = stored.primaryDriverId;
|
||||
}
|
||||
return new User(userProps);
|
||||
}
|
||||
|
||||
public getId(): UserId {
|
||||
return this.id;
|
||||
}
|
||||
@@ -59,6 +79,10 @@ export class User {
|
||||
return this.email;
|
||||
}
|
||||
|
||||
public getPasswordHash(): PasswordHash | undefined {
|
||||
return this.passwordHash;
|
||||
}
|
||||
|
||||
public getIracingCustomerId(): string | undefined {
|
||||
return this.iracingCustomerId;
|
||||
}
|
||||
|
||||
19
core/identity/domain/repositories/IAuthRepository.ts
Normal file
19
core/identity/domain/repositories/IAuthRepository.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { EmailAddress } from '../value-objects/EmailAddress';
|
||||
import { User } from '../entities/User';
|
||||
|
||||
/**
|
||||
* Domain Repository: IAuthRepository
|
||||
*
|
||||
* Repository interface for authentication operations.
|
||||
*/
|
||||
export interface IAuthRepository {
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
findByEmail(email: EmailAddress): Promise<User | null>;
|
||||
|
||||
/**
|
||||
* Save a user
|
||||
*/
|
||||
save(user: User): Promise<void>;
|
||||
}
|
||||
26
core/identity/domain/services/PasswordHashingService.ts
Normal file
26
core/identity/domain/services/PasswordHashingService.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PasswordHash } from '../value-objects/PasswordHash';
|
||||
|
||||
/**
|
||||
* Domain Service: PasswordHashingService
|
||||
*
|
||||
* Service for password hashing and verification.
|
||||
*/
|
||||
export interface IPasswordHashingService {
|
||||
hash(plain: string): Promise<string>;
|
||||
verify(plain: string, hash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation using bcrypt via PasswordHash VO.
|
||||
*/
|
||||
export class PasswordHashingService implements IPasswordHashingService {
|
||||
async hash(plain: string): Promise<string> {
|
||||
const passwordHash = await PasswordHash.create(plain);
|
||||
return passwordHash.value;
|
||||
}
|
||||
|
||||
async verify(plain: string, hash: string): Promise<boolean> {
|
||||
const passwordHash = PasswordHash.fromHash(hash);
|
||||
return passwordHash.verify(plain);
|
||||
}
|
||||
}
|
||||
41
core/identity/domain/value-objects/PasswordHash.ts
Normal file
41
core/identity/domain/value-objects/PasswordHash.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface PasswordHashProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: PasswordHash
|
||||
*
|
||||
* Wraps a bcrypt-hashed password string and provides verification.
|
||||
*/
|
||||
export class PasswordHash implements IValueObject<PasswordHashProps> {
|
||||
public readonly props: PasswordHashProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
static async create(plain: string): Promise<PasswordHash> {
|
||||
const saltRounds = 12;
|
||||
const hash = await bcrypt.hash(plain, saltRounds);
|
||||
return new PasswordHash(hash);
|
||||
}
|
||||
|
||||
static fromHash(hash: string): PasswordHash {
|
||||
return new PasswordHash(hash);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
async verify(plain: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, this.props.value);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<PasswordHashProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface UserIdProps {
|
||||
@@ -14,6 +15,10 @@ export class UserId implements IValueObject<UserIdProps> {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
public static create(): UserId {
|
||||
return new UserId(uuidv4());
|
||||
}
|
||||
|
||||
public static fromString(value: string): UserId {
|
||||
return new UserId(value);
|
||||
}
|
||||
|
||||
16
core/league/application/ports/ILeagueStandingsRepository.ts
Normal file
16
core/league/application/ports/ILeagueStandingsRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface RawStanding {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
// These properties might be optional or present depending on the data source
|
||||
seasonId?: string;
|
||||
podiums?: number;
|
||||
}
|
||||
|
||||
export interface ILeagueStandingsRepository {
|
||||
getLeagueStandings(leagueId: string): Promise<RawStanding[]>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface GetLeagueStandingsUseCase {
|
||||
execute(leagueId: string): Promise<LeagueStandingsViewModel>;
|
||||
}
|
||||
|
||||
export interface StandingItemViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export interface LeagueStandingsViewModel {
|
||||
leagueId: string;
|
||||
standings: StandingItemViewModel[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase';
|
||||
import { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository';
|
||||
|
||||
export class GetLeagueStandingsUseCaseImpl implements GetLeagueStandingsUseCase {
|
||||
constructor(private repository: ILeagueStandingsRepository) {}
|
||||
|
||||
async execute(leagueId: string): Promise<LeagueStandingsViewModel> {
|
||||
const rawStandings = await this.repository.getLeagueStandings(leagueId);
|
||||
|
||||
const standingItems: StandingItemViewModel[] = rawStandings.map((standing: RawStanding) => {
|
||||
return {
|
||||
id: standing.id,
|
||||
leagueId: standing.leagueId,
|
||||
seasonId: standing.seasonId ?? '',
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
podiums: standing.podiums ?? 0,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leagueId: leagueId,
|
||||
standings: standingItems,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export * from './src/faker/faker';
|
||||
export * from './src/images/images';
|
||||
export * from './src/media/DemoAvatarGenerationAdapter';
|
||||
export * from './src/media/DemoFaceValidationAdapter';
|
||||
export * from './src/media/DemoImageServiceAdapter';
|
||||
export * from './src/media/InMemoryAvatarGenerationRepository';
|
||||
export * from './src/racing/RacingSeedCore';
|
||||
export * from './src/racing/RacingSponsorshipSeed';
|
||||
export * from './src/racing/RacingFeedSeed';
|
||||
export * from './src/racing/RacingStaticSeed';
|
||||
export * from './src/racing/DemoTracks';
|
||||
export * from './src/racing/DemoCars';
|
||||
export * from './src/racing/DemoDriverStats';
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@gridpilot/testing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
203
package-lock.json
generated
203
package-lock.json
generated
@@ -14,16 +14,19 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@gridpilot/social": "file:core/social",
|
||||
"bcrypt": "^6.0.0",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tsyringe": "^4.10.0"
|
||||
"tsyringe": "^4.10.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
@@ -323,6 +326,92 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"apps/website/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"core/analytics": {
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/automation": {
|
||||
"name": "@gridpilot/automation",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/identity": {
|
||||
"name": "@gridpilot/identity",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
},
|
||||
"core/media": {
|
||||
"name": "@gridpilot/media",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/notifications": {
|
||||
"name": "@gridpilot/notifications",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"core/notifications/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"core/racing": {
|
||||
"name": "@gridpilot/racing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"core/racing/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"core/social": {
|
||||
"name": "@gridpilot/social",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/testing-support": {
|
||||
"name": "@gridpilot/testing-support",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||
@@ -4088,6 +4177,16 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -5925,6 +6024,20 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -12115,6 +12228,15 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -12157,6 +12279,17 @@
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -16750,6 +16883,19 @@
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/typeorm/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -16992,16 +17138,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
@@ -17649,53 +17795,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"core/analytics": {
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/automation": {
|
||||
"name": "@gridpilot/automation",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/identity": {
|
||||
"name": "@gridpilot/identity",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
},
|
||||
"core/media": {
|
||||
"name": "@gridpilot/media",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/notifications": {
|
||||
"name": "@gridpilot/notifications",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"core/racing": {
|
||||
"name": "@gridpilot/racing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"core/social": {
|
||||
"name": "@gridpilot/social",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"core/testing-support": {
|
||||
"name": "@gridpilot/testing-support",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,10 @@
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"cheerio": "^1.0.0",
|
||||
"commander": "^11.0.0",
|
||||
@@ -85,9 +86,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gridpilot/social": "file:core/social",
|
||||
"bcrypt": "^6.0.0",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tsyringe": "^4.10.0"
|
||||
"tsyringe": "^4.10.0",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
180
plans/auth-clean-arch.md
Normal file
180
plans/auth-clean-arch.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Clean Architecture Compliant Auth Layer Design
|
||||
|
||||
## Current State Analysis (Diagnosis)
|
||||
- Existing [`core/identity/index.ts](core/identity/index.ts) exports User entity, IUserRepository port, but lacks credential-based auth ports (IAuthRepository), value objects (PasswordHash), and use cases (LoginUseCase, SignupUseCase).
|
||||
- iRacing-specific auth use cases present (StartAuthUseCase, HandleAuthCallbackUseCase), but no traditional email/password flows.
|
||||
- No domain services for password hashing/validation.
|
||||
- In-memory impls limited to ratings/achievements; missing User/Auth repo impls.
|
||||
- [`apps/website/lib/auth/InMemoryAuthService.ts](apps/website/lib/auth/InMemoryAuthService.ts) (visible) embeds business logic, violating dependency inversion.
|
||||
- App-layer AuthService exists but thick; no thin delivery via DI-injected use cases.
|
||||
- No AuthContext integration with clean AuthService.
|
||||
- DI setup in [`apps/website/lib/di-tokens.ts](apps/website/lib/di-tokens.ts) etc. needs auth bindings.
|
||||
|
||||
## Architectural Plan
|
||||
1. Add `PasswordHash` value object in `core/identity/domain/value-objects/PasswordHash.ts`.
|
||||
2. Create `IAuthRepository` port in `core/identity/domain/repositories/IAuthRepository.ts` with `findByCredentials(email: EmailAddress, passwordHash: PasswordHash): Promise<User | null>`, `save(user: User): Promise<void>`.
|
||||
3. Extend `IUserRepository` if needed for non-auth user ops.
|
||||
4. Implement `InMemoryUserRepository` in `adapters/identity/inmem/InMemoryUserRepository.ts` satisfying `IUserRepository & IAuthRepository`.
|
||||
5. Add domain service `PasswordHashingService` in `core/identity/domain/services/PasswordHashingService.ts` (interface + dummy impl).
|
||||
6. Create `LoginUseCase` in `core/identity/application/use-cases/LoginUseCase.ts` orchestrating repo find + hashing service.
|
||||
7. Create `SignupUseCase` in `core/identity/application/use-cases/SignupUseCase.ts` validating, hashing, save via repos.
|
||||
8. Create `GetUserUseCase` in `core/identity/application/use-cases/GetUserUseCase.ts` via IUserRepository.
|
||||
9. Refactor `apps/website/lib/auth/AuthService.ts` as thin adapter: DTO -> use case calls via injected deps.
|
||||
10. Update `apps/website/lib/auth/AuthContext.tsx` to provide/use AuthService via React Context.
|
||||
11. Add DI tokens in `apps/website/lib/di-tokens.ts`: `AUTH_REPOSITORY_TOKEN`, `USER_REPOSITORY_TOKEN`, `LOGIN_USE_CASE_TOKEN`, etc.
|
||||
12. Bind in `apps/website/lib/di-config.ts` / `di-container.ts`: ports -> inmem impls, use cases -> deps, AuthService -> use cases.
|
||||
|
||||
## Summary
|
||||
Layer auth into domain entities/VOs/services, application use cases/ports, infrastructure adapters (inmem), thin app delivery (AuthService) wired via DI. Coexists with existing iRacing provider auth.
|
||||
|
||||
## Design Overview
|
||||
Follows strict Clean Architecture:
|
||||
- **Entities**: User with EmailAddress, PasswordHash VOs.
|
||||
- **Use Cases**: Pure orchestrators, depend on ports/services.
|
||||
- **Ports**: IAuthRepository (credential ops), IUserRepository (user data).
|
||||
- **Adapters**: Inmem impls.
|
||||
- **Delivery**: AuthService maps HTTP/JS DTOs to use cases.
|
||||
- **DI**: Inversion via tokens/container.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
DTO[DTOs<br/>apps/website/lib/auth] --> AuthService[AuthService<br/>apps/website/lib/auth]
|
||||
AuthService --> LoginUC[LoginUseCase<br/>core/identity/application]
|
||||
AuthService --> SignupUC[SignupUseCase]
|
||||
LoginUC --> IAuthRepo[IAuthRepository<br/>core/identity/domain]
|
||||
SignupUC --> PasswordSvc[PasswordHashingService<br/>core/identity/domain]
|
||||
IAuthRepo --> InMemRepo[InMemoryUserRepository<br/>adapters/identity/inmem]
|
||||
AuthService -.-> DI[DI Container<br/>apps/website/lib/di-*]
|
||||
AuthContext[AuthContext.tsx] --> AuthService
|
||||
```
|
||||
|
||||
## Files Structure
|
||||
```
|
||||
core/identity/
|
||||
├── domain/
|
||||
│ ├── value-objects/PasswordHash.ts (new)
|
||||
│ ├── entities/User.ts (extend if needed)
|
||||
│ ├── repositories/IAuthRepository.ts (new)
|
||||
│ └── services/PasswordHashingService.ts (new)
|
||||
├── application/
|
||||
│ └── use-cases/LoginUseCase.ts (new)
|
||||
│ SignupUseCase.ts (new)
|
||||
│ GetUserUseCase.ts (new)
|
||||
adapters/identity/inmem/
|
||||
├── InMemoryUserRepository.ts (new)
|
||||
apps/website/lib/auth/
|
||||
├── AuthService.ts (refactor)
|
||||
└── AuthContext.tsx (update)
|
||||
apps/website/lib/
|
||||
├── di-tokens.ts (update)
|
||||
├── di-config.ts (update)
|
||||
└── di-container.ts (update)
|
||||
```
|
||||
|
||||
## Code Snippets
|
||||
|
||||
### PasswordHash VO
|
||||
```ts
|
||||
// core/identity/domain/value-objects/PasswordHash.ts
|
||||
import { ValueObject } from '../../../shared/domain/ValueObject'; // assume shared
|
||||
|
||||
export class PasswordHash extends ValueObject<string> {
|
||||
static create(plain: string): PasswordHash {
|
||||
// dummy bcrypt hash
|
||||
return new PasswordHash(btoa(plain)); // prod: use bcrypt
|
||||
}
|
||||
|
||||
verify(plain: string): boolean {
|
||||
return btoa(plain) === this.value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### IAuthRepository Port
|
||||
```ts
|
||||
// core/identity/domain/repositories/IAuthRepository.ts
|
||||
import { UserId, EmailAddress } from '../value-objects';
|
||||
import { User } from '../entities/User';
|
||||
import { PasswordHash } from '../value-objects/PasswordHash';
|
||||
|
||||
export interface IAuthRepository {
|
||||
findByCredentials(email: EmailAddress, passwordHash: PasswordHash): Promise<User | null>;
|
||||
save(user: User): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### LoginUseCase
|
||||
```ts
|
||||
// core/identity/application/use-cases/LoginUseCase.ts
|
||||
import { Injectable } from 'di'; // assume
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { User } from '../../domain/entities/User';
|
||||
|
||||
export class LoginUseCase {
|
||||
constructor(
|
||||
private authRepo: IAuthRepository
|
||||
) {}
|
||||
|
||||
async execute(email: string, password: string): Promise<User> {
|
||||
const emailVO = EmailAddress.create(email);
|
||||
const passwordHash = PasswordHash.create(password);
|
||||
const user = await this.authRepo.findByCredentials(emailVO, passwordHash);
|
||||
if (!user) throw new Error('Invalid credentials');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthService (Thin Adapter)
|
||||
```ts
|
||||
// apps/website/lib/auth/AuthService.ts
|
||||
import { injectable, inject } from 'di'; // assume
|
||||
import { LoginUseCase } from '@gridpilot/identity';
|
||||
import type { LoginDto } from '@gridpilot/identity/application/dto'; // define DTO
|
||||
|
||||
@injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@inject(LoginUseCase_TOKEN) private loginUC: LoginUseCase
|
||||
) {}
|
||||
|
||||
async login(credentials: {email: string, password: string}) {
|
||||
return await this.loginUC.execute(credentials.email, credentials.password);
|
||||
}
|
||||
// similar for signup, getUser
|
||||
}
|
||||
```
|
||||
|
||||
### DI Tokens Update
|
||||
```ts
|
||||
// apps/website/lib/di-tokens.ts
|
||||
export const LOGIN_USE_CASE_TOKEN = Symbol('LoginUseCase');
|
||||
export const AUTH_REPOSITORY_TOKEN = Symbol('IAuthRepository');
|
||||
// etc.
|
||||
```
|
||||
|
||||
### AuthContext Usage Example
|
||||
```tsx
|
||||
// apps/website/lib/auth/AuthContext.tsx
|
||||
import { createContext, useContext } from 'react';
|
||||
import { AuthService } from './AuthService';
|
||||
import { diContainer } from '../di-container';
|
||||
|
||||
const AuthContext = createContext<AuthService>(null!);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const authService = diContainer.resolve(AuthService);
|
||||
return <AuthContext.Provider value={authService}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
- Update `core/identity/index.ts` exports for new modules.
|
||||
- Update `core/identity/package.json` exports if needed.
|
||||
- Use dummy hashing for dev; prod adapter swaps repo impl.
|
||||
- No business logic in app/website: all in core use cases.
|
||||
- Coexists with iRacing auth: separate use cases/services.
|
||||
49
testing/factories/racing/ChampionshipConfigFactory.ts
Normal file
49
testing/factories/racing/ChampionshipConfigFactory.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SessionType } from '@gridpilot/racing/domain/types/SessionType';
|
||||
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
|
||||
import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule';
|
||||
import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig';
|
||||
import { makePointsTable } from './PointsTableFactory';
|
||||
|
||||
export const makeChampionshipConfig = (params: {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionTypes: SessionType[];
|
||||
mainPoints: number[];
|
||||
sprintPoints?: number[];
|
||||
mainBonusRules?: BonusRule[];
|
||||
}): ChampionshipConfig => {
|
||||
const { id, name, sessionTypes, mainPoints, sprintPoints, mainBonusRules } = params;
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {} as Record<SessionType, PointsTable>;
|
||||
|
||||
sessionTypes.forEach((sessionType) => {
|
||||
if (sessionType === 'main') {
|
||||
pointsTableBySessionType[sessionType] = makePointsTable(mainPoints);
|
||||
} else if (sessionType === 'sprint' && sprintPoints) {
|
||||
pointsTableBySessionType[sessionType] = makePointsTable(sprintPoints);
|
||||
} else {
|
||||
pointsTableBySessionType[sessionType] = new PointsTable({});
|
||||
}
|
||||
});
|
||||
|
||||
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {} as Record<SessionType, BonusRule[]>;
|
||||
sessionTypes.forEach((sessionType) => {
|
||||
if (sessionType === 'main' && mainBonusRules) {
|
||||
bonusRulesBySessionType[sessionType] = mainBonusRules;
|
||||
} else {
|
||||
bonusRulesBySessionType[sessionType] = [];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'driver',
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy: {
|
||||
strategy: 'none',
|
||||
},
|
||||
};
|
||||
};
|
||||
7
testing/factories/racing/DriverRefFactory.ts
Normal file
7
testing/factories/racing/DriverRefFactory.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef';
|
||||
import type { ChampionshipType } from '@gridpilot/racing/domain/types/ChampionshipType';
|
||||
|
||||
export const makeDriverRef = (id: string): ParticipantRef => ({
|
||||
type: 'driver' as ChampionshipType,
|
||||
id,
|
||||
});
|
||||
9
testing/factories/racing/PointsTableFactory.ts
Normal file
9
testing/factories/racing/PointsTableFactory.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
|
||||
|
||||
export const makePointsTable = (points: number[]): PointsTable => {
|
||||
const pointsByPosition: Record<number, number> = {};
|
||||
points.forEach((value, index) => {
|
||||
pointsByPosition[index + 1] = value;
|
||||
});
|
||||
return new PointsTable(pointsByPosition);
|
||||
};
|
||||
23
testing/factories/racing/SeasonFactory.ts
Normal file
23
testing/factories/racing/SeasonFactory.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||
import type { SeasonStatus } from '@gridpilot/racing/domain/entities/Season';
|
||||
|
||||
export const createMinimalSeason = (overrides?: { status?: SeasonStatus }) =>
|
||||
Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: overrides?.status ?? 'planned',
|
||||
});
|
||||
|
||||
export const createBaseSeason = () =>
|
||||
Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Config Season',
|
||||
status: 'planned',
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: undefined,
|
||||
maxDrivers: 24,
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { Result } from '@gridpilot/racing/domain/entities/Result';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem';
|
||||
import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO';
|
||||
import { faker } from '../faker/faker';
|
||||
import { getLeagueBanner, getDriverAvatar } from '../images/images';
|
||||
import { faker } from '../../helpers/faker/faker';
|
||||
import { getLeagueBanner, getDriverAvatar } from '../../helpers/images/images';
|
||||
import type { Friendship, RacingMembership } from './RacingSeedCore';
|
||||
|
||||
/**
|
||||
@@ -3,7 +3,8 @@ 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 { faker } from '../faker/faker';
|
||||
import { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
|
||||
import { faker } from '../../helpers/faker/faker';
|
||||
|
||||
/**
|
||||
* Core racing seed types and generators (drivers, leagues, teams, races, standings).
|
||||
@@ -292,7 +293,7 @@ export function createRaces(leagues: League[]): Race[] {
|
||||
scheduledAt,
|
||||
track: faker.helpers.arrayElement(tracks),
|
||||
car: faker.helpers.arrayElement(cars),
|
||||
sessionType: 'race',
|
||||
sessionType: SessionType.main(),
|
||||
status,
|
||||
...(strengthOfField !== undefined ? { strengthOfField } : {}),
|
||||
...(status === 'running' ? { registeredCount: faker.number.int({ min: 12, max: 20 }) } : {}),
|
||||
@@ -7,8 +7,8 @@ import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem';
|
||||
import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO';
|
||||
|
||||
import { faker } from '../faker/faker';
|
||||
import { getTeamLogo } from '../images/images';
|
||||
import { faker } from '../../helpers/faker/faker';
|
||||
import { getTeamLogo } from '../../helpers/images/images';
|
||||
|
||||
import {
|
||||
createDrivers,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsEntityId } from '../../../../../core/analytics/domain/value-objects/AnalyticsEntityId';
|
||||
import { AnalyticsEntityId } from '../../../core/analytics/domain/value-objects/AnalyticsEntityId';
|
||||
|
||||
describe('AnalyticsEntityId', () => {
|
||||
it('creates a valid AnalyticsEntityId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsSessionId } from '../../../../../core/analytics/domain/value-objects/AnalyticsSessionId';
|
||||
import { AnalyticsSessionId } from '../../../core/analytics/domain/value-objects/AnalyticsSessionId';
|
||||
|
||||
describe('AnalyticsSessionId', () => {
|
||||
it('creates a valid AnalyticsSessionId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageViewId } from '../../../../../core/analytics/domain/value-objects/PageViewId';
|
||||
import { PageViewId } from '../../../core/analytics/domain/value-objects/PageViewId';
|
||||
|
||||
describe('PageViewId', () => {
|
||||
it('creates a valid PageViewId from a non-empty string', () => {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user