league service

This commit is contained in:
2025-12-16 00:57:31 +01:00
parent 3b566c973d
commit 775d41e055
130 changed files with 4077 additions and 1036 deletions

View File

@@ -11,6 +11,7 @@ import AlphaFooter from '@/components/alpha/AlphaFooter';
import { AuthProvider } from '@/lib/auth/AuthContext';
import NotificationProvider from '@/components/notifications/NotificationProvider';
import DevToolbar from '@/components/dev/DevToolbar';
import { initializeDIContainer } from '@/lib/di-setup';
export const dynamic = 'force-dynamic';
@@ -49,6 +50,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
await initializeDIContainer();
const mode = getAppMode();
if (mode === 'alpha') {

View File

@@ -3,12 +3,8 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import {
loadLeagueSchedule,
registerForRace,
withdrawFromRace,
type LeagueScheduleRaceItemViewModel,
} from '@/lib/presenters/LeagueSchedulePresenter';
import { createLeagueSchedulePresenter } from '@/lib/presenters/factories';
import type { LeagueScheduleRaceItemViewModel } from '@/lib/presenters/LeagueSchedulePresenter';
interface LeagueScheduleProps {
leagueId: string;

View File

@@ -4,12 +4,11 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import {
loadScheduleRaceFormLeagues,
scheduleRaceFromForm,
type ScheduleRaceFormData,
type ScheduledRaceViewModel,
type LeagueOptionViewModel,
import { createScheduleRaceFormPresenter } from '@/lib/presenters/factories';
import type {
ScheduleRaceFormData,
ScheduledRaceViewModel,
LeagueOptionViewModel,
} from '@/lib/presenters/ScheduleRaceFormPresenter';
interface ScheduleRaceFormProps {

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { LeagueModule } from './modules/league/LeagueModule';
import { DriverModule } from './modules/driver/DriverModule';
import { TeamModule } from './modules/team/TeamModule';
import { RaceModule } from './modules/race/RaceModule';
import { SponsorModule } from './modules/sponsor/SponsorModule';
import { AuthModule } from './modules/auth/AuthModule';
import { MediaModule } from './modules/media/MediaModule';
import { AnalyticsModule } from './modules/analytics/AnalyticsModule';
import { LoggingModule } from './modules/logging/LoggingModule';
@Module({
imports: [
LoggingModule,
LeagueModule,
DriverModule,
TeamModule,
RaceModule,
SponsorModule,
AuthModule,
MediaModule,
AnalyticsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { INestApplicationContext } from '@nestjs/common';
import { AppModule } from './app.module';
let appContext: INestApplicationContext | null = null;
export async function initializeDIContainer(): Promise<void> {
if (appContext) {
return; // Already initialized
}
appContext = await NestFactory.createApplicationContext(AppModule);
}
export function getDIContainer(): INestApplicationContext {
if (!appContext) {
throw new Error('DI container not initialized. Call initializeDIContainer() first.');
}
return appContext;
}
export async function getService<T>(token: string | symbol): Promise<T> {
const container = getDIContainer();
return container.get<T>(token);
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AnalyticsProviders, PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN } from './AnalyticsProviders';
@Module({
imports: [],
providers: AnalyticsProviders,
exports: [PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,30 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryPageViewRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
import { InMemoryEngagementRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const PAGE_VIEW_REPOSITORY_TOKEN = Symbol('IPageViewRepository');
export const ENGAGEMENT_REPOSITORY_TOKEN = Symbol('IEngagementRepository');
export const AnalyticsProviders: Provider[] = [
{
provide: PAGE_VIEW_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: ENGAGEMENT_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuthProviders, AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
@Module({
imports: [],
providers: AuthProviders,
exports: [AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN],
})
export class AuthModule {}

View File

@@ -0,0 +1,30 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { IAuthRepository } from '@gridpilot/identity/domain/repositories/IAuthRepository';
import { IUserRepository } from '@gridpilot/identity/domain/repositories/IUserRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryAuthRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryAuthRepository';
import { InMemoryUserRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryUserRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const AUTH_REPOSITORY_TOKEN = Symbol('IAuthRepository');
export const USER_REPOSITORY_TOKEN = Symbol('IUserRepository');
export const AuthProviders: Provider[] = [
{
provide: AUTH_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryAuthRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: USER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryUserRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DriverProviders, DRIVER_REPOSITORY_TOKEN } from './DriverProviders';
@Module({
imports: [],
providers: DriverProviders,
exports: [DRIVER_REPOSITORY_TOKEN],
})
export class DriverModule {}

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryDriverRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryDriverRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const DRIVER_REPOSITORY_TOKEN = Symbol('IDriverRepository');
export const DriverProviders: Provider[] = [
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LeagueProviders, GET_LEAGUE_STANDINGS_USE_CASE_TOKEN } from './LeagueProviders';
@Module({
imports: [],
providers: LeagueProviders,
exports: [GET_LEAGUE_STANDINGS_USE_CASE_TOKEN],
})
export class LeagueModule {}

View File

@@ -0,0 +1,30 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { GetLeagueStandingsUseCase } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCase';
import { ILeagueStandingsRepository } from '@gridpilot/league/application/ports/ILeagueStandingsRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { GetLeagueStandingsUseCaseImpl } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCaseImpl';
import { InMemoryLeagueStandingsRepository } from '@gridpilot/adapters/league/persistence/inmemory/InMemoryLeagueStandingsRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const GET_LEAGUE_STANDINGS_USE_CASE_TOKEN = Symbol('GetLeagueStandingsUseCase');
export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = Symbol('ILeagueStandingsRepository');
export const LeagueProviders: Provider[] = [
{
provide: GET_LEAGUE_STANDINGS_USE_CASE_TOKEN,
useFactory: (repository: ILeagueStandingsRepository, logger: Logger) => new GetLeagueStandingsUseCaseImpl(repository),
inject: [LEAGUE_STANDINGS_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: LEAGUE_STANDINGS_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { Logger } from '@gridpilot/shared/logging/Logger';
import { ConsoleLogger } from '@gridpilot/adapters/logging/ConsoleLogger';
export const LOGGER_TOKEN = Symbol('Logger');
@Global()
@Module({
providers: [
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
],
exports: [LOGGER_TOKEN],
})
export class LoggingModule {}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MediaProviders, AVATAR_GENERATION_REPOSITORY_TOKEN } from './MediaProviders';
@Module({
imports: [],
providers: MediaProviders,
exports: [AVATAR_GENERATION_REPOSITORY_TOKEN],
})
export class MediaModule {}

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { IAvatarGenerationRepository } from '@gridpilot/media/domain/repositories/IAvatarGenerationRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryAvatarGenerationRepository } from '@gridpilot/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const AVATAR_GENERATION_REPOSITORY_TOKEN = Symbol('IAvatarGenerationRepository');
export const MediaProviders: Provider[] = [
{
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryAvatarGenerationRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RaceProviders, RACE_REPOSITORY_TOKEN } from './RaceProviders';
@Module({
imports: [],
providers: RaceProviders,
exports: [RACE_REPOSITORY_TOKEN],
})
export class RaceModule {}

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryRaceRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryRaceRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const RACE_REPOSITORY_TOKEN = Symbol('IRaceRepository');
export const RaceProviders: Provider[] = [
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SponsorProviders, SPONSOR_REPOSITORY_TOKEN } from './SponsorProviders';
@Module({
imports: [],
providers: SponsorProviders,
exports: [SPONSOR_REPOSITORY_TOKEN],
})
export class SponsorModule {}

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemorySponsorRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemorySponsorRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const SPONSOR_REPOSITORY_TOKEN = Symbol('ISponsorRepository');
export const SponsorProviders: Provider[] = [
{
provide: SPONSOR_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemorySponsorRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TeamProviders, TEAM_REPOSITORY_TOKEN } from './TeamProviders';
@Module({
imports: [],
providers: TeamProviders,
exports: [TEAM_REPOSITORY_TOKEN],
})
export class TeamModule {}

View File

@@ -0,0 +1,22 @@
import { Provider } from '@nestjs/common';
// Import core interfaces
import { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import { Logger } from '@gridpilot/shared/logging/Logger';
// Import implementations
import { InMemoryTeamRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryTeamRepository';
// Import tokens
import { LOGGER_TOKEN } from '../logging/LoggingModule';
// Define injection tokens
export const TEAM_REPOSITORY_TOKEN = Symbol('ITeamRepository');
export const TeamProviders: Provider[] = [
{
provide: TEAM_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -1,10 +1,8 @@
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import {
getRaceRepository,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import type { IRaceRepository } from '@gridpilot/racing/application/ports/IRaceRepository';
import type { IIsDriverRegisteredForRaceQuery } from '@gridpilot/racing/application/queries/IIsDriverRegisteredForRaceQuery';
import type { IRegisterForRaceUseCase } from '@gridpilot/racing/application/use-cases/IRegisterForRaceUseCase';
import type { IWithdrawFromRaceUseCase } from '@gridpilot/racing/application/use-cases/IWithdrawFromRaceUseCase';
export interface LeagueScheduleRaceItemViewModel {
id: string;
@@ -23,85 +21,95 @@ export interface LeagueScheduleViewModel {
races: LeagueScheduleRaceItemViewModel[];
}
/**
* Load league schedule with registration status for a given driver.
*/
export async function loadLeagueSchedule(
leagueId: string,
driverId: string,
): Promise<LeagueScheduleViewModel> {
const raceRepo = getRaceRepository();
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
export interface ILeagueSchedulePresenter {
loadLeagueSchedule(leagueId: string, driverId: string): Promise<LeagueScheduleViewModel>;
registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void>;
withdrawFromRace(raceId: string, driverId: string): Promise<void>;
}
const allRaces = await raceRepo.findAll();
const leagueRaces = allRaces
.filter((race) => race.leagueId === leagueId)
.sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
export class LeagueSchedulePresenter implements ILeagueSchedulePresenter {
constructor(
private raceRepository: IRaceRepository,
private isDriverRegisteredForRaceQuery: IIsDriverRegisteredForRaceQuery,
private registerForRaceUseCase: IRegisterForRaceUseCase,
private withdrawFromRaceUseCase: IWithdrawFromRaceUseCase,
) {}
/**
* Load league schedule with registration status for a given driver.
*/
async loadLeagueSchedule(
leagueId: string,
driverId: string,
): Promise<LeagueScheduleViewModel> {
const allRaces = await this.raceRepository.findAll();
const leagueRaces = allRaces
.filter((race) => race.leagueId === leagueId)
.sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
const now = new Date();
const registrationStates: Record<string, boolean> = {};
await Promise.all(
leagueRaces.map(async (race) => {
const registered = await this.isDriverRegisteredForRaceQuery.execute({
raceId: race.id,
driverId,
});
registrationStates[race.id] = registered;
}),
);
const now = new Date();
const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => {
const raceDate = new Date(race.scheduledAt);
const isPast = race.status === 'completed' || raceDate <= now;
const isUpcoming = race.status === 'scheduled' && raceDate > now;
const registrationStates: Record<string, boolean> = {};
await Promise.all(
leagueRaces.map(async (race) => {
const registered = await isRegisteredQuery.execute({
raceId: race.id,
driverId,
});
registrationStates[race.id] = registered;
}),
);
return {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
sessionType: race.sessionType,
scheduledAt: raceDate,
status: race.status,
isUpcoming,
isPast,
isRegistered: registrationStates[race.id] ?? false,
};
});
const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => {
const raceDate = new Date(race.scheduledAt);
const isPast = race.status === 'completed' || raceDate <= now;
const isUpcoming = race.status === 'scheduled' && raceDate > now;
return { races };
}
return {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
sessionType: race.sessionType,
scheduledAt: raceDate,
status: race.status,
isUpcoming,
isPast,
isRegistered: registrationStates[race.id] ?? false,
};
});
/**
* Register the driver for a race.
*/
async registerForRace(
raceId: string,
leagueId: string,
driverId: string,
): Promise<void> {
await this.registerForRaceUseCase.execute({
raceId,
leagueId,
driverId,
});
}
return { races };
}
/**
* Register the driver for a race.
*/
export async function registerForRace(
raceId: string,
leagueId: string,
driverId: string,
): Promise<void> {
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId,
leagueId,
driverId,
});
}
/**
* Withdraw the driver from a race.
*/
export async function withdrawFromRace(
raceId: string,
driverId: string,
): Promise<void> {
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId,
driverId,
});
/**
* Withdraw the driver from a race.
*/
async withdrawFromRace(
raceId: string,
driverId: string,
): Promise<void> {
await this.withdrawFromRaceUseCase.execute({
raceId,
driverId,
});
}
}

View File

@@ -0,0 +1,101 @@
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type {
LeagueMembership as DomainLeagueMembership,
MembershipRole,
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
/**
* Lightweight league membership model mirroring the domain type but with
* a stringified joinedAt for easier UI formatting.
*/
export interface LeagueMembership extends Omit<DomainLeagueMembership, 'joinedAt'> {
joinedAt: string;
}
export class LeagueMembershipService {
private leagueMemberships = new Map<string, LeagueMembership[]>();
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
) {
this.initializeLeagueMembershipsFromRepository();
}
/**
* Initialize league memberships once from the in-memory league membership repository
* that is seeded via the static racing seed in the DI container.
*
* This avoids depending on raw testing-support seed exports and keeps all demo
* membership data flowing through the same in-memory repositories used elsewhere.
*/
private async initializeLeagueMembershipsFromRepository() {
if (this.leagueMemberships.size > 0) {
return;
}
try {
const allLeagues = await this.leagueRepository.findAll();
const byLeague = new Map<string, LeagueMembership[]>();
for (const league of allLeagues) {
const memberships = await this.membershipRepository.getLeagueMembers(league.id);
const mapped: LeagueMembership[] = memberships.map((membership) => ({
id: membership.id,
leagueId: membership.leagueId,
driverId: membership.driverId,
role: membership.role,
status: membership.status,
joinedAt:
membership.joinedAt instanceof Date
? membership.joinedAt.toISOString()
: new Date().toISOString(),
}));
byLeague.set(league.id, mapped);
}
for (const [leagueId, list] of byLeague.entries()) {
this.leagueMemberships.set(leagueId, list);
}
} catch (error) {
// In alpha/demo mode we tolerate failures here; callers will see empty memberships.
// eslint-disable-next-line no-console
console.error('Failed to initialize league memberships from repository', error);
}
}
getMembership(leagueId: string, driverId: string): LeagueMembership | null {
const list = this.leagueMemberships.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
getLeagueMembers(leagueId: string): LeagueMembership[] {
return [...(this.leagueMemberships.get(leagueId) ?? [])];
}
/**
* Derive a driver's primary league from in-memory league memberships.
* Prefers any active membership and returns the first matching league.
*/
getPrimaryLeagueIdForDriver(driverId: string): string | null {
for (const [leagueId, members] of this.leagueMemberships.entries()) {
if (members.some((m) => m.driverId === driverId && m.status === 'active')) {
return leagueId;
}
}
return null;
}
isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = this.getMembership(leagueId, driverId);
if (!membership) return false;
return membership.role === 'owner' || membership.role === 'admin';
}
}
export type { MembershipRole, MembershipStatus };