wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -6,8 +6,10 @@ import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO
import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO";
import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel";
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
import { LeagueScheduleViewModel } from "@/lib/view-models/LeagueScheduleViewModel";
import { LeagueScheduleViewModel, type LeagueScheduleRaceViewModel } from "@/lib/view-models/LeagueScheduleViewModel";
import { LeagueSeasonSummaryViewModel } from "@/lib/view-models/LeagueSeasonSummaryViewModel";
import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewModel";
import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel";
import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel";
@@ -15,12 +17,22 @@ import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel";
import { LeaguePageDetailViewModel } from "@/lib/view-models/LeaguePageDetailViewModel";
import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import type { LeagueAdminRosterJoinRequestViewModel } from "@/lib/view-models/LeagueAdminRosterJoinRequestViewModel";
import type { LeagueAdminRosterMemberViewModel } from "@/lib/view-models/LeagueAdminRosterMemberViewModel";
import type { MembershipRole } from "@/lib/types/MembershipRole";
import type { LeagueRosterJoinRequestDTO } from "@/lib/types/generated/LeagueRosterJoinRequestDTO";
import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
import { RaceDTO } from "@/lib/types/generated/RaceDTO";
import type { RaceDTO } from "@/lib/types/generated/RaceDTO";
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
import { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO";
import type { LeagueMembership } from "@/lib/types/LeagueMembership";
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { CreateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/CreateLeagueScheduleRaceInputDTO';
import type { CreateLeagueScheduleRaceOutputDTO } from '@/lib/types/generated/CreateLeagueScheduleRaceOutputDTO';
import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO';
import type { LeagueScheduleRaceMutationSuccessDTO } from '@/lib/types/generated/LeagueScheduleRaceMutationSuccessDTO';
import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated/LeagueSeasonSchedulePublishOutputDTO';
/**
@@ -29,6 +41,58 @@ import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonS
* Orchestrates league operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
function parseIsoDate(value: string, fallback: Date): Date {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return fallback;
return parsed;
}
function getBestEffortIsoDate(race: RaceDTO): string | undefined {
const anyRace = race as unknown as { scheduledAt?: unknown; date?: unknown };
if (typeof anyRace.scheduledAt === 'string') return anyRace.scheduledAt;
if (typeof anyRace.date === 'string') return anyRace.date;
return undefined;
}
function getOptionalStringField(race: RaceDTO, key: string): string | undefined {
const anyRace = race as unknown as Record<string, unknown>;
const value = anyRace[key];
return typeof value === 'string' ? value : undefined;
}
function getOptionalBooleanField(race: RaceDTO, key: string): boolean | undefined {
const anyRace = race as unknown as Record<string, unknown>;
const value = anyRace[key];
return typeof value === 'boolean' ? value : undefined;
}
function mapLeagueScheduleDtoToRaceViewModels(dto: LeagueScheduleDTO, now: Date = new Date()): LeagueScheduleRaceViewModel[] {
return dto.races.map((race) => {
const iso = getBestEffortIsoDate(race);
const scheduledAt = iso ? parseIsoDate(iso, new Date(0)) : new Date(0);
const isPast = scheduledAt.getTime() < now.getTime();
const isUpcoming = !isPast;
const status = getOptionalStringField(race, 'status') ?? (isPast ? 'completed' : 'scheduled');
return {
id: race.id,
name: race.name,
scheduledAt,
isPast,
isUpcoming,
status,
track: getOptionalStringField(race, 'track'),
car: getOptionalStringField(race, 'car'),
sessionType: getOptionalStringField(race, 'sessionType'),
isRegistered: getOptionalBooleanField(race, 'isRegistered'),
};
});
}
export class LeagueService {
private readonly submitBlocker = new SubmitBlocker();
private readonly throttle = new ThrottleBlocker(500);
@@ -103,10 +167,128 @@ export class LeagueService {
/**
* Get league schedule
*
* Service boundary: returns ViewModels only (no DTOs / mappers in UI).
*/
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
const dto = await this.apiClient.getSchedule(leagueId);
return new LeagueScheduleViewModel(dto);
const races = mapLeagueScheduleDtoToRaceViewModels(dto);
return new LeagueScheduleViewModel(races);
}
/**
* Admin schedule editor API (ViewModel boundary)
*/
async getLeagueSeasonSummaries(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
const dtos = await this.apiClient.getSeasons(leagueId);
return dtos.map((dto) => new LeagueSeasonSummaryViewModel(dto));
}
async getAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
const dto = await this.apiClient.getSchedule(leagueId, seasonId);
const races = mapLeagueScheduleDtoToRaceViewModels(dto);
return new LeagueAdminScheduleViewModel({
seasonId: dto.seasonId,
published: dto.published,
races,
});
}
async publishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
await this.apiClient.publishSeasonSchedule(leagueId, seasonId);
return this.getAdminSchedule(leagueId, seasonId);
}
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
await this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
return this.getAdminSchedule(leagueId, seasonId);
}
async createAdminScheduleRace(
leagueId: string,
seasonId: string,
input: { track: string; car: string; scheduledAtIso: string },
): Promise<LeagueAdminScheduleViewModel> {
const payload: CreateLeagueScheduleRaceInputDTO = { ...input, example: '' };
await this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload);
return this.getAdminSchedule(leagueId, seasonId);
}
async updateAdminScheduleRace(
leagueId: string,
seasonId: string,
raceId: string,
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
): Promise<LeagueAdminScheduleViewModel> {
const payload: UpdateLeagueScheduleRaceInputDTO = { ...input, example: '' };
await this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload);
return this.getAdminSchedule(leagueId, seasonId);
}
async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise<LeagueAdminScheduleViewModel> {
await this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
return this.getAdminSchedule(leagueId, seasonId);
}
/**
* Legacy DTO methods (kept for existing callers)
*/
/**
* Get league schedule DTO (season-scoped)
*
* Admin UI uses the raw DTO so it can render `published` and do CRUD refreshes.
*/
async getLeagueScheduleDto(leagueId: string, seasonId: string): Promise<LeagueScheduleDTO> {
return this.apiClient.getSchedule(leagueId, seasonId);
}
/**
* Publish a league season schedule
*/
async publishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.apiClient.publishSeasonSchedule(leagueId, seasonId);
}
/**
* Unpublish a league season schedule
*/
async unpublishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
}
/**
* Create a schedule race for a league season
*/
async createLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
input: CreateLeagueScheduleRaceInputDTO,
): Promise<CreateLeagueScheduleRaceOutputDTO> {
return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, input);
}
/**
* Update a schedule race for a league season
*/
async updateLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
raceId: string,
input: UpdateLeagueScheduleRaceInputDTO,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, input);
}
/**
* Delete a schedule race for a league season
*/
async deleteLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
raceId: string,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
}
/**
@@ -143,17 +325,83 @@ export class LeagueService {
/**
* Remove a member from league
*
* Overload:
* - Legacy: removeMember(leagueId, performerDriverId, targetDriverId)
* - Admin roster: removeMember(leagueId, targetDriverId) (actor derived from session)
*/
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<RemoveMemberViewModel> {
const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
return new RemoveMemberViewModel(dto);
async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }>;
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<RemoveMemberViewModel>;
async removeMember(leagueId: string, arg1: string, arg2?: string): Promise<{ success: boolean } | RemoveMemberViewModel> {
if (arg2 === undefined) {
const dto = await this.apiClient.removeRosterMember(leagueId, arg1);
return { success: dto.success };
}
const dto = await this.apiClient.removeMember(leagueId, arg1, arg2);
return new RemoveMemberViewModel(dto as any);
}
/**
* Update a member's role in league
*
* Overload:
* - Legacy: updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole)
* - Admin roster: updateMemberRole(leagueId, targetDriverId, newRole) (actor derived from session)
*/
async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> {
return this.apiClient.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole);
async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }>;
async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }>;
async updateMemberRole(leagueId: string, arg1: string, arg2: string, arg3?: string): Promise<{ success: boolean }> {
if (arg3 === undefined) {
const dto = await this.apiClient.updateRosterMemberRole(leagueId, arg1, arg2);
return { success: dto.success };
}
return this.apiClient.updateMemberRole(leagueId, arg1, arg2, arg3);
}
/**
* Admin roster: members list as ViewModels
*/
async getAdminRosterMembers(leagueId: string): Promise<LeagueAdminRosterMemberViewModel[]> {
const dtos = await this.apiClient.getAdminRosterMembers(leagueId);
return dtos.map((dto) => ({
driverId: dto.driverId,
driverName: dto.driver?.name ?? dto.driverId,
role: (dto.role as MembershipRole) ?? 'member',
joinedAtIso: dto.joinedAt,
}));
}
/**
* Admin roster: join requests list as ViewModels
*/
async getAdminRosterJoinRequests(leagueId: string): Promise<LeagueAdminRosterJoinRequestViewModel[]> {
const dtos = await this.apiClient.getAdminRosterJoinRequests(leagueId);
return dtos.map((dto) => ({
id: dto.id,
leagueId: dto.leagueId,
driverId: dto.driverId,
driverName: this.resolveJoinRequestDriverName(dto),
requestedAtIso: dto.requestedAt,
message: dto.message,
}));
}
async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> {
const dto = await this.apiClient.approveRosterJoinRequest(leagueId, joinRequestId);
return { success: dto.success };
}
async rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> {
const dto = await this.apiClient.rejectRosterJoinRequest(leagueId, joinRequestId);
return { success: dto.success };
}
private resolveJoinRequestDriverName(dto: LeagueRosterJoinRequestDTO): string {
const driver = dto.driver as any;
const name = driver && typeof driver === 'object' ? (driver.name as string | undefined) : undefined;
return name ?? dto.driverId;
}
/**