refactor to adapters

This commit is contained in:
2025-12-15 18:34:20 +01:00
parent fc671482c8
commit c817d76092
145 changed files with 906 additions and 361 deletions

View 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.
}
}

View File

@@ -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));
}

View 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.
}

View 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 {}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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'),

View File

@@ -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 {

View File

@@ -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"],

View 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);
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}

View 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>;
}

View 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);
}
}

View 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;
}
}

View File

@@ -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);
}

View 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[]>;
}

View File

@@ -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[];
}

View File

@@ -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,
};
}
}

View File

@@ -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';

View File

@@ -1,7 +0,0 @@
{
"name": "@gridpilot/testing",
"version": "0.1.0",
"private": true,
"main": "./index.ts",
"types": "./index.ts"
}

View File

@@ -1,9 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": ["**/*.ts"]
}

203
package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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
View 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.

View 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',
},
};
};

View 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,
});

View 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);
};

View 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,
});

View File

@@ -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';
/**

View File

@@ -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 }) } : {}),

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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