This commit is contained in:
2025-12-11 00:57:32 +01:00
parent 1303a14493
commit 6a427eab57
112 changed files with 6148 additions and 2272 deletions

View File

@@ -0,0 +1,16 @@
import type {
IAllRacesPagePresenter,
AllRacesPageViewModel,
} from '@gridpilot/racing/application/presenters/IAllRacesPagePresenter';
export class AllRacesPagePresenter implements IAllRacesPagePresenter {
private viewModel: AllRacesPageViewModel | null = null;
present(viewModel: AllRacesPageViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): AllRacesPageViewModel | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,16 @@
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
} from '@gridpilot/racing/application/presenters/IDashboardOverviewPresenter';
export class DashboardOverviewPresenter implements IDashboardOverviewPresenter {
private viewModel: DashboardOverviewViewModel | null = null;
present(viewModel: DashboardOverviewViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): DashboardOverviewViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,5 +1,5 @@
import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter';
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingQuery';
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
private data: GetEntitySponsorshipPricingResultDTO | null = null;

View File

@@ -0,0 +1,17 @@
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '@gridpilot/racing/application/presenters/IImportRaceResultsPresenter';
export class ImportRaceResultsPresenter implements IImportRaceResultsPresenter {
private viewModel: ImportRaceResultsSummaryViewModel | null = null;
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
this.viewModel = viewModel;
return this.viewModel;
}
getViewModel(): ImportRaceResultsSummaryViewModel | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,292 @@
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { MembershipRole } from '@/lib/leagueMembership';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
getLeagueMembershipRepository,
getDriverRepository,
getGetLeagueFullConfigUseCase,
getRaceRepository,
getProtestRepository,
getDriverStats,
getAllDriverRankings,
} from '@/lib/di-container';
export interface LeagueJoinRequestViewModel {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
driver?: DriverDTO;
}
export interface ProtestDriverSummary {
[driverId: string]: DriverDTO;
}
export interface ProtestRaceSummary {
[raceId: string]: Race;
}
export interface LeagueOwnerSummaryViewModel {
driver: DriverDTO;
rating: number | null;
rank: number | null;
}
export interface LeagueAdminProtestsViewModel {
protests: Protest[];
racesById: ProtestRaceSummary;
driversById: ProtestDriverSummary;
}
export interface LeagueAdminConfigViewModel {
form: LeagueConfigFormModel | null;
}
export interface LeagueAdminPermissionsViewModel {
canRemoveMember: boolean;
canUpdateRoles: boolean;
}
export interface LeagueAdminViewModel {
joinRequests: LeagueJoinRequestViewModel[];
ownerSummary: LeagueOwnerSummaryViewModel | null;
config: LeagueAdminConfigViewModel;
protests: LeagueAdminProtestsViewModel;
}
/**
* Load join requests plus requester driver DTOs for a league.
*/
export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(leagueId);
const driverRepo = getDriverRepository();
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id)));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const driversById: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
driversById[dto.id] = dto;
}
return requests.map((request) => ({
id: request.id,
leagueId: request.leagueId,
driverId: request.driverId,
requestedAt: request.requestedAt,
message: request.message,
driver: driversById[request.driverId],
}));
}
/**
* Approve a league join request and return updated join requests.
*/
export async function approveLeagueJoinRequest(
leagueId: string,
requestId: string
): Promise<LeagueJoinRequestViewModel[]> {
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(leagueId);
const request = requests.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
await membershipRepo.saveMembership({
leagueId: request.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
});
await membershipRepo.removeJoinRequest(requestId);
return loadLeagueJoinRequests(leagueId);
}
/**
* Reject a league join request (alpha: just remove).
*/
export async function rejectLeagueJoinRequest(
leagueId: string,
requestId: string
): Promise<LeagueJoinRequestViewModel[]> {
const membershipRepo = getLeagueMembershipRepository();
await membershipRepo.removeJoinRequest(requestId);
return loadLeagueJoinRequests(leagueId);
}
/**
* Compute permissions for a performer on league membership actions.
*/
export async function getLeagueAdminPermissions(
leagueId: string,
performerDriverId: string
): Promise<LeagueAdminPermissionsViewModel> {
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
const isOwner = performer?.role === 'owner';
const isAdmin = performer?.role === 'admin';
return {
canRemoveMember: Boolean(isOwner || isAdmin),
canUpdateRoles: Boolean(isOwner),
};
}
/**
* Remove a member from the league, enforcing simple role rules.
*/
export async function removeLeagueMember(
leagueId: string,
performerDriverId: string,
targetDriverId: string
): Promise<void> {
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the league owner');
}
await membershipRepo.removeMembership(leagueId, targetDriverId);
}
/**
* Update a member's role, enforcing simple owner-only rules.
*/
export async function updateLeagueMemberRole(
leagueId: string,
performerDriverId: string,
targetDriverId: string,
newRole: MembershipRole
): Promise<void> {
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
if (!performer || performer.role !== 'owner') {
throw new Error('Only the league owner can update roles');
}
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
await membershipRepo.saveMembership({
...membership,
role: newRole,
});
}
/**
* Load owner summary (DTO + rating/rank) for a league.
*/
export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwnerSummaryViewModel | null> {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(league.ownerId);
if (!entity) return null;
const ownerDriver = EntityMappers.toDriverDTO(entity);
const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: ownerDriver,
rating,
rank,
};
}
/**
* Load league full config form.
*/
export async function loadLeagueConfig(leagueId: string): Promise<LeagueAdminConfigViewModel> {
const useCase = getGetLeagueFullConfigUseCase();
const form = await useCase.execute({ leagueId });
return { form };
}
/**
* Load protests, related races and driver DTOs for a league.
*/
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
const raceRepo = getRaceRepository();
const protestRepo = getProtestRepository();
const driverRepo = getDriverRepository();
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
const allProtests: Protest[] = [];
const racesById: Record<string, Race> = {};
for (const race of leagueRaces) {
racesById[race.id] = race;
const raceProtests = await protestRepo.findByRaceId(race.id);
allProtests.push(...raceProtests);
}
const driverIds = new Set<string>();
allProtests.forEach((p) => {
driverIds.add(p.protestingDriverId);
driverIds.add(p.accusedDriverId);
});
const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id)));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const driversById: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
driversById[dto.id] = dto;
}
return {
protests: allProtests,
racesById,
driversById,
};
}

View File

@@ -0,0 +1,107 @@
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import {
getRaceRepository,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
export interface LeagueScheduleRaceItemViewModel {
id: string;
leagueId: string;
track: string;
car: string;
sessionType: string;
scheduledAt: Date;
status: Race['status'];
isUpcoming: boolean;
isPast: boolean;
isRegistered: boolean;
}
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();
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(),
);
const now = new Date();
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;
}),
);
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 {
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,
};
});
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,
});
}

View File

@@ -1,5 +1,5 @@
import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter';
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsQuery';
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
private data: GetPendingSponsorshipRequestsResultDTO | null = null;

View File

@@ -0,0 +1,16 @@
import type {
IProfileOverviewPresenter,
ProfileOverviewViewModel,
} from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
export class ProfileOverviewPresenter implements IProfileOverviewPresenter {
private viewModel: ProfileOverviewViewModel | null = null;
present(viewModel: ProfileOverviewViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): ProfileOverviewViewModel | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,17 @@
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
} from '@gridpilot/racing/application/presenters/IRaceDetailPresenter';
export class RaceDetailPresenter implements IRaceDetailPresenter {
private viewModel: RaceDetailViewModel | null = null;
present(viewModel: RaceDetailViewModel): RaceDetailViewModel {
this.viewModel = viewModel;
return this.viewModel;
}
getViewModel(): RaceDetailViewModel | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,17 @@
import type {
IRaceResultsDetailPresenter,
RaceResultsDetailViewModel,
} from '@gridpilot/racing/application/presenters/IRaceResultsDetailPresenter';
export class RaceResultsDetailPresenter implements IRaceResultsDetailPresenter {
private viewModel: RaceResultsDetailViewModel | null = null;
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel {
this.viewModel = viewModel;
return this.viewModel;
}
getViewModel(): RaceResultsDetailViewModel | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,71 @@
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
export type SessionType = 'practice' | 'qualifying' | 'race';
export interface ScheduleRaceFormData {
leagueId: string;
track: string;
car: string;
sessionType: SessionType;
scheduledDate: string;
scheduledTime: string;
}
export interface ScheduledRaceViewModel {
id: string;
leagueId: string;
track: string;
car: string;
sessionType: SessionType;
scheduledAt: Date;
status: string;
}
export interface LeagueOptionViewModel {
id: string;
name: string;
}
/**
* Presenter/Facade for the schedule race form.
* Encapsulates all domain/repository access so the component can stay purely presentational.
*/
export async function loadScheduleRaceFormLeagues(): Promise<LeagueOptionViewModel[]> {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
return allLeagues.map((league) => ({
id: league.id,
name: league.name,
}));
}
export async function scheduleRaceFromForm(
formData: ScheduleRaceFormData
): Promise<ScheduledRaceViewModel> {
const raceRepo = getRaceRepository();
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const race = Race.create({
id: InMemoryRaceRepository.generateId(),
leagueId: formData.leagueId,
track: formData.track.trim(),
car: formData.car.trim(),
sessionType: formData.sessionType,
scheduledAt,
status: 'scheduled',
});
const createdRace = await raceRepo.create(race);
return {
id: createdRace.id,
leagueId: createdRace.leagueId,
track: createdRace.track,
car: createdRace.car,
sessionType: createdRace.sessionType as SessionType,
scheduledAt: createdRace.scheduledAt,
status: createdRace.status,
};
}

View File

@@ -1,5 +1,5 @@
import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter';
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardQuery';
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardUseCase';
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
private data: SponsorDashboardDTO | null = null;

View File

@@ -1,5 +1,5 @@
import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter';
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsQuery';
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsUseCase';
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
private data: SponsorSponsorshipsDTO | null = null;

View File

@@ -0,0 +1,59 @@
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
export interface TeamRosterMemberViewModel {
driver: DriverDTO;
role: TeamRole;
joinedAt: string;
rating: number | null;
overallRank: number | null;
}
export interface TeamRosterViewModel {
members: TeamRosterMemberViewModel[];
averageRating: number;
}
/**
* Presenter/facade for team roster.
* Encapsulates repository and stats access so the TeamRoster component can remain a pure view.
*/
export async function getTeamRosterViewModel(
memberships: TeamMembership[]
): Promise<TeamRosterViewModel> {
const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll();
const members: TeamRosterMemberViewModel[] = [];
for (const membership of memberships) {
const driver = allDrivers.find((d) => d.id === membership.driverId);
if (!driver) continue;
const dto = EntityMappers.toDriverDTO(driver);
if (!dto) continue;
const stats = getDriverStats(membership.driverId);
members.push({
driver: dto,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
rating: stats?.rating ?? null,
overallRank: typeof stats?.overallRank === 'number' ? stats.overallRank : null,
});
}
const averageRating =
members.length > 0
? Math.round(
members.reduce((sum, m) => sum + (m.rating ?? 0), 0) / members.length
)
: 0;
return {
members,
averageRating,
};
}

View File

@@ -9,9 +9,35 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
private viewModel: TeamsLeaderboardViewModel | null = null;
present(teams: any[], recruitingCount: number): void {
const transformedTeams = teams.map((team) => this.transformTeam(team));
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
(acc, team) => {
if (!acc[team.performanceLevel]) {
acc[team.performanceLevel] = [];
}
acc[team.performanceLevel]!.push(team);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
},
);
const topTeams = transformedTeams
.filter((t) => t.rating !== null)
.slice()
.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))
.slice(0, 5);
this.viewModel = {
teams: teams.map((team) => this.transformTeam(team)),
teams: transformedTeams,
recruitingCount,
groupsBySkillLevel,
topTeams,
};
}