wip league admin tools
This commit is contained in:
@@ -68,6 +68,7 @@ export default function LeagueLayout({
|
||||
];
|
||||
|
||||
const adminTabs = [
|
||||
{ label: 'Schedule Admin', href: `/leagues/${leagueId}/schedule/admin`, exact: false },
|
||||
{ label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false },
|
||||
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
|
||||
{ label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false },
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
||||
import { RosterAdminPage } from './RosterAdminPage';
|
||||
|
||||
type RosterAdminLeagueService = {
|
||||
getAdminRosterJoinRequests(leagueId: string): Promise<LeagueAdminRosterJoinRequestViewModel[]>;
|
||||
getAdminRosterMembers(leagueId: string): Promise<LeagueAdminRosterMemberViewModel[]>;
|
||||
approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
|
||||
rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
|
||||
updateMemberRole(leagueId: string, driverId: string, role: string): Promise<{ success: boolean }>;
|
||||
removeMember(leagueId: string, driverId: string): Promise<{ success: boolean }>;
|
||||
};
|
||||
|
||||
let mockLeagueService: Mocked<RosterAdminLeagueService>;
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'league-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return {
|
||||
...actual,
|
||||
useServices: () => ({
|
||||
leagueService: mockLeagueService,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewModel> = {}): LeagueAdminRosterJoinRequestViewModel {
|
||||
return {
|
||||
id: 'jr-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver One',
|
||||
requestedAtIso: '2025-01-01T00:00:00.000Z',
|
||||
message: 'Please let me in',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
|
||||
return {
|
||||
driverId: 'driver-10',
|
||||
driverName: 'Member Ten',
|
||||
role: 'member',
|
||||
joinedAtIso: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RosterAdminPage', () => {
|
||||
beforeEach(() => {
|
||||
mockLeagueService = {
|
||||
getAdminRosterJoinRequests: vi.fn(),
|
||||
getAdminRosterMembers: vi.fn(),
|
||||
approveJoinRequest: vi.fn(),
|
||||
rejectJoinRequest: vi.fn(),
|
||||
updateMemberRole: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('renders join requests + members from service ViewModels', async () => {
|
||||
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
|
||||
makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' }),
|
||||
makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two', driverId: 'driver-2' }),
|
||||
];
|
||||
|
||||
const members: LeagueAdminRosterMemberViewModel[] = [
|
||||
makeMember({ driverId: 'driver-10', driverName: 'Member Ten', role: 'member' }),
|
||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }),
|
||||
];
|
||||
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue(joinRequests);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue(members);
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Roster Admin')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('Driver One')).toBeInTheDocument();
|
||||
expect(screen.getByText('Driver Two')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('Member Ten')).toBeInTheDocument();
|
||||
expect(screen.getByText('Member Eleven')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('approves a join request and removes it from the pending list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
|
||||
mockLeagueService.approveJoinRequest.mockResolvedValue({ success: true } as any);
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Driver One')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('join-request-jr-1-approve'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.approveJoinRequest).toHaveBeenCalledWith('league-1', 'jr-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Driver One')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a join request and removes it from the pending list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
|
||||
mockLeagueService.rejectJoinRequest.mockResolvedValue({ success: true } as any);
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Driver Two')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('join-request-jr-2-reject'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.rejectJoinRequest).toHaveBeenCalledWith('league-1', 'jr-2');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Driver Two')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('changes a member role via service and updates the displayed role', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
|
||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' }),
|
||||
]);
|
||||
mockLeagueService.updateMemberRole.mockResolvedValue({ success: true } as any);
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Member Eleven')).toBeInTheDocument();
|
||||
|
||||
const roleSelect = screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement;
|
||||
expect(roleSelect.value).toBe('member');
|
||||
|
||||
fireEvent.change(roleSelect, { target: { value: 'admin' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.updateMemberRole).toHaveBeenCalledWith('league-1', 'driver-11', 'admin');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement).value).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a member via service and removes them from the list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
|
||||
makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' }),
|
||||
]);
|
||||
mockLeagueService.removeMember.mockResolvedValue({ success: true } as any);
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Member Twelve')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('member-driver-12-remove'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.removeMember).toHaveBeenCalledWith('league-1', 'driver-12');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
180
apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx
Normal file
180
apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
export function RosterAdminPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joinRequests, setJoinRequests] = useState<LeagueAdminRosterJoinRequestViewModel[]>([]);
|
||||
const [members, setMembers] = useState<LeagueAdminRosterMemberViewModel[]>([]);
|
||||
|
||||
const loadRoster = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [requestsVm, membersVm] = await Promise.all([
|
||||
leagueService.getAdminRosterJoinRequests(leagueId),
|
||||
leagueService.getAdminRosterMembers(leagueId),
|
||||
]);
|
||||
setJoinRequests(requestsVm);
|
||||
setMembers(membersVm);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoster();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
const pendingCountLabel = useMemo(() => {
|
||||
return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`;
|
||||
}, [joinRequests.length]);
|
||||
|
||||
const handleApprove = async (joinRequestId: string) => {
|
||||
await leagueService.approveJoinRequest(leagueId, joinRequestId);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
|
||||
};
|
||||
|
||||
const handleReject = async (joinRequestId: string) => {
|
||||
await leagueService.rejectJoinRequest(leagueId, joinRequestId);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
|
||||
};
|
||||
|
||||
const handleRoleChange = async (driverId: string, newRole: MembershipRole) => {
|
||||
setMembers((prev) => prev.map((m) => (m.driverId === driverId ? { ...m, role: newRole } : m)));
|
||||
const result = await leagueService.updateMemberRole(leagueId, driverId, newRole);
|
||||
if (!result.success) {
|
||||
await loadRoster();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (driverId: string) => {
|
||||
await leagueService.removeMember(leagueId, driverId);
|
||||
setMembers((prev) => prev.filter((m) => m.driverId !== driverId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Roster Admin</h1>
|
||||
<p className="text-sm text-gray-400">Manage join requests and member roles.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Pending join requests</h2>
|
||||
<p className="text-xs text-gray-500">{pendingCountLabel}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-400">Loading…</div>
|
||||
) : joinRequests.length ? (
|
||||
<div className="space-y-2">
|
||||
{joinRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{req.driverName}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{req.requestedAtIso}</p>
|
||||
{req.message ? <p className="text-xs text-gray-500 truncate">{req.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`join-request-${req.id}-approve`}
|
||||
onClick={() => handleApprove(req.id)}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`join-request-${req.id}-reject`}
|
||||
onClick={() => handleReject(req.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No pending join requests.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Members</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-400">Loading…</div>
|
||||
) : members.length ? (
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.driverId}
|
||||
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{member.driverName}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{member.joinedAtIso}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
||||
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
|
||||
Role for {member.driverName}
|
||||
</label>
|
||||
<select
|
||||
id={`role-${member.driverId}`}
|
||||
aria-label={`Role for ${member.driverName}`}
|
||||
value={member.role}
|
||||
onChange={(e) => handleRoleChange(member.driverId, e.target.value as MembershipRole)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`member-${member.driverId}-remove`}
|
||||
onClick={() => handleRemove(member.driverId)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No members found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/website/app/leagues/[id]/roster/admin/page.tsx
Normal file
7
apps/website/app/leagues/[id]/roster/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { RosterAdminPage } from './RosterAdminPage';
|
||||
|
||||
export default function LeagueRosterAdminPage() {
|
||||
return <RosterAdminPage />;
|
||||
}
|
||||
277
apps/website/app/leagues/[id]/schedule/admin/page.test.tsx
Normal file
277
apps/website/app/leagues/[id]/schedule/admin/page.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
|
||||
import LeagueAdminSchedulePage from './page';
|
||||
|
||||
type SeasonSummaryViewModel = {
|
||||
seasonId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
isPrimary: boolean;
|
||||
isParallelActive: boolean;
|
||||
};
|
||||
|
||||
type AdminScheduleRaceViewModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: Date;
|
||||
};
|
||||
|
||||
type AdminScheduleViewModel = {
|
||||
seasonId: string;
|
||||
published: boolean;
|
||||
races: AdminScheduleRaceViewModel[];
|
||||
};
|
||||
|
||||
const mockGetLeagueSeasonSummaries = vi.fn<() => Promise<SeasonSummaryViewModel[]>>();
|
||||
|
||||
const mockGetAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
|
||||
|
||||
const mockGetLeagueScheduleDto = vi.fn(() => {
|
||||
throw new Error('LeagueAdminSchedulePage must not call getLeagueScheduleDto (DTO boundary violation)');
|
||||
});
|
||||
|
||||
const mockPublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
|
||||
const mockUnpublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
|
||||
|
||||
const mockCreateAdminScheduleRace = vi.fn<
|
||||
(leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }) => Promise<AdminScheduleViewModel>
|
||||
>();
|
||||
const mockUpdateAdminScheduleRace = vi.fn<
|
||||
(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
|
||||
) => Promise<AdminScheduleViewModel>
|
||||
>();
|
||||
const mockDeleteAdminScheduleRace = vi.fn<(leagueId: string, seasonId: string, raceId: string) => Promise<AdminScheduleViewModel>>();
|
||||
|
||||
const mockFetchLeagueMemberships = vi.fn<(leagueId: string) => Promise<unknown[]>>();
|
||||
const mockGetMembership = vi.fn<
|
||||
(leagueId: string, driverId: string) => { role: 'admin' | 'owner' | 'member' | 'steward' } | null
|
||||
>();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'league-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
const mockServices = {
|
||||
leagueService: {
|
||||
getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries,
|
||||
|
||||
getAdminSchedule: mockGetAdminSchedule,
|
||||
|
||||
publishAdminSchedule: mockPublishAdminSchedule,
|
||||
unpublishAdminSchedule: mockUnpublishAdminSchedule,
|
||||
|
||||
createAdminScheduleRace: mockCreateAdminScheduleRace,
|
||||
updateAdminScheduleRace: mockUpdateAdminScheduleRace,
|
||||
deleteAdminScheduleRace: mockDeleteAdminScheduleRace,
|
||||
|
||||
// Legacy method (should never be called by this page)
|
||||
getLeagueScheduleDto: mockGetLeagueScheduleDto,
|
||||
},
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => mockServices,
|
||||
}));
|
||||
|
||||
function createAdminScheduleViewModel(overrides: Partial<AdminScheduleViewModel> = {}): AdminScheduleViewModel {
|
||||
return {
|
||||
seasonId: 'season-1',
|
||||
published: false,
|
||||
races: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LeagueAdminSchedulePage', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetLeagueSeasonSummaries.mockReset();
|
||||
mockGetAdminSchedule.mockReset();
|
||||
mockGetLeagueScheduleDto.mockClear();
|
||||
|
||||
mockPublishAdminSchedule.mockReset();
|
||||
mockUnpublishAdminSchedule.mockReset();
|
||||
mockCreateAdminScheduleRace.mockReset();
|
||||
mockUpdateAdminScheduleRace.mockReset();
|
||||
mockDeleteAdminScheduleRace.mockReset();
|
||||
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
|
||||
mockFetchLeagueMemberships.mockResolvedValue([]);
|
||||
mockGetMembership.mockReturnValue({ role: 'admin' });
|
||||
});
|
||||
|
||||
it('renders schedule using ViewModel fields (no DTO date field)', async () => {
|
||||
mockGetLeagueSeasonSummaries.mockResolvedValue([
|
||||
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
|
||||
]);
|
||||
|
||||
mockGetAdminSchedule.mockResolvedValue(
|
||||
createAdminScheduleViewModel({
|
||||
seasonId: 'season-1',
|
||||
published: true,
|
||||
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
|
||||
}),
|
||||
);
|
||||
|
||||
render(<LeagueAdminSchedulePage />);
|
||||
|
||||
expect(await screen.findByText('Schedule Admin')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Race 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
});
|
||||
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publish/unpublish uses admin schedule service API and updates UI status', async () => {
|
||||
mockGetLeagueSeasonSummaries.mockResolvedValue([
|
||||
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
|
||||
]);
|
||||
|
||||
mockGetAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
|
||||
|
||||
mockPublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: true }));
|
||||
mockUnpublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
|
||||
|
||||
render(<LeagueAdminSchedulePage />);
|
||||
|
||||
expect(await screen.findByText(/Status:/)).toHaveTextContent('Unpublished');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
});
|
||||
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Publish' })).toBeEnabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Unpublish' })).toBeEnabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unpublish' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUnpublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Status:/)).toHaveTextContent('Unpublished');
|
||||
});
|
||||
});
|
||||
|
||||
it('create/update/delete uses admin schedule service API and refreshes schedule list', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
mockGetLeagueSeasonSummaries.mockResolvedValue([
|
||||
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
|
||||
]);
|
||||
|
||||
mockGetAdminSchedule.mockResolvedValueOnce(createAdminScheduleViewModel({ published: false, races: [] }));
|
||||
|
||||
mockCreateAdminScheduleRace.mockResolvedValueOnce(
|
||||
createAdminScheduleViewModel({
|
||||
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
|
||||
}),
|
||||
);
|
||||
|
||||
mockUpdateAdminScheduleRace.mockResolvedValueOnce(
|
||||
createAdminScheduleViewModel({
|
||||
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-02T12:00:00.000Z') }],
|
||||
}),
|
||||
);
|
||||
|
||||
mockDeleteAdminScheduleRace.mockResolvedValueOnce(createAdminScheduleViewModel({ races: [] }));
|
||||
|
||||
render(<LeagueAdminSchedulePage />);
|
||||
|
||||
await screen.findByText('Schedule Admin');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
});
|
||||
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
await screen.findByLabelText('Track');
|
||||
await screen.findByLabelText('Car');
|
||||
await screen.findByLabelText('Scheduled At (ISO)');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Track'), { target: { value: 'Laguna Seca' } });
|
||||
fireEvent.change(screen.getByLabelText('Car'), { target: { value: 'MX-5' } });
|
||||
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-01T12:00:00.000Z' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add race' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', {
|
||||
track: 'Laguna Seca',
|
||||
car: 'MX-5',
|
||||
scheduledAtIso: '2025-01-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Race 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-02T12:00:00.000Z' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1', {
|
||||
scheduledAtIso: '2025-01-02T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('2025-01-02T12:00:00.000Z')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Race 1')).toBeNull();
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
331
apps/website/app/leagues/[id]/schedule/admin/page.tsx
Normal file
331
apps/website/app/leagues/[id]/schedule/admin/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
||||
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function LeagueAdminSchedulePage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService, leagueMembershipService } = useServices();
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [membershipLoading, setMembershipLoading] = useState(true);
|
||||
|
||||
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
|
||||
const [seasonId, setSeasonId] = useState<string>('');
|
||||
|
||||
const [schedule, setSchedule] = useState<LeagueAdminScheduleViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [track, setTrack] = useState('');
|
||||
const [car, setCar] = useState('');
|
||||
const [scheduledAtIso, setScheduledAtIso] = useState('');
|
||||
|
||||
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
||||
const isEditing = editingRaceId !== null;
|
||||
|
||||
const publishedLabel = schedule?.published ? 'Published' : 'Unpublished';
|
||||
|
||||
const selectedSeasonLabel = useMemo(() => {
|
||||
const selected = seasons.find((s) => s.seasonId === seasonId);
|
||||
return selected?.name ?? seasonId;
|
||||
}, [seasons, seasonId]);
|
||||
|
||||
const loadSchedule = async (leagueIdToLoad: string, seasonIdToLoad: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const vm = await leagueService.getAdminSchedule(leagueIdToLoad, seasonIdToLoad);
|
||||
setSchedule(vm);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
setMembershipLoading(true);
|
||||
try {
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
} finally {
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
setMembershipLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId, leagueMembershipService]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSeasons() {
|
||||
const loaded = await leagueService.getLeagueSeasonSummaries(leagueId);
|
||||
setSeasons(loaded);
|
||||
|
||||
if (loaded.length > 0) {
|
||||
const active = loaded.find((s) => s.status === 'active') ?? loaded[0];
|
||||
setSeasonId(active?.seasonId ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadSeasons();
|
||||
}
|
||||
}, [leagueId, isAdmin, leagueService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
if (!seasonId) return;
|
||||
|
||||
loadSchedule(leagueId, seasonId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId, seasonId, isAdmin]);
|
||||
|
||||
const handlePublishToggle = async () => {
|
||||
if (!schedule) return;
|
||||
|
||||
if (schedule.published) {
|
||||
const vm = await leagueService.unpublishAdminSchedule(leagueId, seasonId);
|
||||
setSchedule(vm);
|
||||
return;
|
||||
}
|
||||
|
||||
const vm = await leagueService.publishAdminSchedule(leagueId, seasonId);
|
||||
setSchedule(vm);
|
||||
};
|
||||
|
||||
const handleAddOrSave = async () => {
|
||||
if (!seasonId) return;
|
||||
|
||||
if (!scheduledAtIso) return;
|
||||
|
||||
if (!isEditing) {
|
||||
const vm = await leagueService.createAdminScheduleRace(leagueId, seasonId, {
|
||||
track,
|
||||
car,
|
||||
scheduledAtIso,
|
||||
});
|
||||
setSchedule(vm);
|
||||
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
return;
|
||||
}
|
||||
|
||||
const vm = await leagueService.updateAdminScheduleRace(leagueId, seasonId, editingRaceId, {
|
||||
...(track ? { track } : {}),
|
||||
...(car ? { car } : {}),
|
||||
...(scheduledAtIso ? { scheduledAtIso } : {}),
|
||||
});
|
||||
|
||||
setSchedule(vm);
|
||||
setEditingRaceId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (raceId: string) => {
|
||||
if (!schedule) return;
|
||||
|
||||
const race = schedule.races.find((r) => r.id === raceId);
|
||||
if (!race) return;
|
||||
|
||||
setEditingRaceId(raceId);
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso(race.scheduledAt.toISOString());
|
||||
};
|
||||
|
||||
const handleDelete = async (raceId: string) => {
|
||||
const confirmed = window.confirm('Delete this race?');
|
||||
if (!confirmed) return;
|
||||
|
||||
const vm = await leagueService.deleteAdminScheduleRace(leagueId, seasonId, raceId);
|
||||
setSchedule(vm);
|
||||
};
|
||||
|
||||
if (membershipLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-400">Loading…</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
|
||||
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm text-gray-300" htmlFor="seasonId">
|
||||
Season
|
||||
</label>
|
||||
{seasons.length > 0 ? (
|
||||
<select
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => setSeasonId(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
>
|
||||
{seasons.map((s) => (
|
||||
<option key={s.seasonId} value={s.seasonId}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => setSeasonId(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="season-id"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-gray-300">
|
||||
Status: <span className="font-medium text-white">{publishedLabel}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishToggle}
|
||||
disabled={!schedule}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
|
||||
>
|
||||
{schedule?.published ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="track" className="text-sm text-gray-300">
|
||||
Track
|
||||
</label>
|
||||
<input
|
||||
id="track"
|
||||
value={track}
|
||||
onChange={(e) => setTrack(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="car" className="text-sm text-gray-300">
|
||||
Car
|
||||
</label>
|
||||
<input
|
||||
id="car"
|
||||
value={car}
|
||||
onChange={(e) => setCar(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
|
||||
Scheduled At (ISO)
|
||||
</label>
|
||||
<input
|
||||
id="scheduledAtIso"
|
||||
value={scheduledAtIso}
|
||||
onChange={(e) => setScheduledAtIso(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="2025-01-01T12:00:00.000Z"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddOrSave}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white"
|
||||
>
|
||||
{isEditing ? 'Save' : 'Add race'}
|
||||
</button>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingRaceId(null)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Races</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-400">Loading schedule…</div>
|
||||
) : schedule?.races.length ? (
|
||||
<div className="space-y-2">
|
||||
{schedule.races.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{race.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(race.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(race.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No races yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import ProtestReviewPage from './page';
|
||||
|
||||
// Mocks for Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useParams: () => ({ id: 'league-1', protestId: 'protest-1' }),
|
||||
}));
|
||||
|
||||
// Mock effective driver id hook
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
const mockGetProtestDetailViewModel = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => ({
|
||||
leagueStewardingService: {
|
||||
getProtestDetailViewModel: mockGetProtestDetailViewModel,
|
||||
},
|
||||
protestService: {
|
||||
applyPenalty: vi.fn(),
|
||||
requestDefense: vi.fn(),
|
||||
},
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockIsLeagueAdminOrHigherRole = vi.fn();
|
||||
|
||||
vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({
|
||||
LeagueRoleUtility: {
|
||||
isLeagueAdminOrHigherRole: (...args: unknown[]) => mockIsLeagueAdminOrHigherRole(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ProtestReviewPage', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
mockGetProtestDetailViewModel.mockReset();
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
mockIsLeagueAdminOrHigherRole.mockReset();
|
||||
|
||||
mockFetchLeagueMemberships.mockResolvedValue(undefined);
|
||||
mockGetMembership.mockReturnValue({ role: 'admin' });
|
||||
mockIsLeagueAdminOrHigherRole.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('loads protest detail via LeagueStewardingService view model method', async () => {
|
||||
mockGetProtestDetailViewModel.mockResolvedValue({
|
||||
protest: {
|
||||
id: 'protest-1',
|
||||
raceId: 'race-1',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
description: 'desc',
|
||||
submittedAt: '2023-10-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
incident: { lap: 1 },
|
||||
},
|
||||
race: {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
formattedDate: '10/1/2023',
|
||||
},
|
||||
protestingDriver: { id: 'driver-1', name: 'Driver 1' },
|
||||
accusedDriver: { id: 'driver-2', name: 'Driver 2' },
|
||||
penaltyTypes: [
|
||||
{
|
||||
type: 'time_penalty',
|
||||
label: 'Time Penalty',
|
||||
description: 'Add seconds to race result',
|
||||
requiresValue: true,
|
||||
valueLabel: 'seconds',
|
||||
defaultValue: 5,
|
||||
},
|
||||
],
|
||||
defaultReasons: { upheld: 'Upheld reason', dismissed: 'Dismissed reason' },
|
||||
initialPenaltyType: 'time_penalty',
|
||||
initialPenaltyValue: 5,
|
||||
});
|
||||
|
||||
render(<ProtestReviewPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetProtestDetailViewModel).toHaveBeenCalledWith('league-1', 'protest-1');
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Protest Review')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,9 @@ import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
||||
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
|
||||
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -99,54 +97,18 @@ const PENALTY_UI: Record<string, PenaltyUiConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 'seconds';
|
||||
case 'grid_positions':
|
||||
return 'positions';
|
||||
case 'points':
|
||||
return 'points';
|
||||
case 'races':
|
||||
return 'races';
|
||||
case 'none':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackDefaultValue(valueKind: PenaltyValueKindDTO): number {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 5;
|
||||
case 'grid_positions':
|
||||
return 3;
|
||||
case 'points':
|
||||
return 5;
|
||||
case 'races':
|
||||
return 1;
|
||||
case 'none':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProtestReviewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { protestService, leagueMembershipService, penaltyService } = useServices();
|
||||
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
|
||||
|
||||
const [protest, setProtest] = useState<ProtestViewModel | null>(null);
|
||||
const [race, setRace] = useState<RaceViewModel | null>(null);
|
||||
const [protestingDriver, setProtestingDriver] = useState<ProtestDriverViewModel | null>(null);
|
||||
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null);
|
||||
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const [penaltyTypesReference, setPenaltyTypesReference] = useState<PenaltyTypesReferenceDTO | null>(null);
|
||||
const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
@@ -156,24 +118,20 @@ export default function ProtestReviewPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = penaltyTypesReference?.penaltyTypes ?? [];
|
||||
const referenceItems = detail?.penaltyTypes ?? [];
|
||||
return referenceItems.map((ref) => {
|
||||
const ui = PENALTY_UI[ref.type] ?? {
|
||||
label: ref.type.replaceAll('_', ' '),
|
||||
description: '',
|
||||
icon: Gavel,
|
||||
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
|
||||
defaultValue: getFallbackDefaultValue(ref.valueKind),
|
||||
};
|
||||
|
||||
return {
|
||||
...ref,
|
||||
...ui,
|
||||
valueLabel: getPenaltyValueLabel(ref.valueKind),
|
||||
defaultValue: ui.defaultValue ?? getFallbackDefaultValue(ref.valueKind),
|
||||
icon: ui.icon,
|
||||
color: ui.color,
|
||||
};
|
||||
});
|
||||
}, [penaltyTypesReference]);
|
||||
}, [detail?.penaltyTypes]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
return penaltyTypes.find((p) => p.type === penaltyType);
|
||||
@@ -195,15 +153,14 @@ export default function ProtestReviewPage() {
|
||||
async function loadProtest() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const protestData = await protestService.getProtestById(leagueId, protestId);
|
||||
if (!protestData) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
|
||||
|
||||
setProtest(protestData.protest);
|
||||
setRace(protestData.race);
|
||||
setProtestingDriver(protestData.protestingDriver);
|
||||
setAccusedDriver(protestData.accusedDriver);
|
||||
setDetail(protestDetail);
|
||||
|
||||
if (protestDetail.initialPenaltyType) {
|
||||
setPenaltyType(protestDetail.initialPenaltyType);
|
||||
setPenaltyValue(protestDetail.initialPenaltyValue);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load protest:', err);
|
||||
alert('Failed to load protest details');
|
||||
@@ -216,43 +173,18 @@ export default function ProtestReviewPage() {
|
||||
if (isAdmin) {
|
||||
loadProtest();
|
||||
}
|
||||
}, [protestId, leagueId, isAdmin, router, protestService]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPenaltyTypes() {
|
||||
if (!isAdmin) return;
|
||||
if (penaltyTypesReference) return;
|
||||
|
||||
setPenaltyTypesLoading(true);
|
||||
try {
|
||||
const ref = await penaltyService.getPenaltyTypesReference();
|
||||
setPenaltyTypesReference(ref);
|
||||
|
||||
const hasSelected = ref.penaltyTypes.some((p) => p.type === penaltyType);
|
||||
const [first] = ref.penaltyTypes;
|
||||
if (!hasSelected && first) {
|
||||
setPenaltyType(first.type);
|
||||
setPenaltyValue(PENALTY_UI[first.type]?.defaultValue ?? getFallbackDefaultValue(first.valueKind));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load penalty types reference:', err);
|
||||
} finally {
|
||||
setPenaltyTypesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadPenaltyTypes();
|
||||
}, [isAdmin, penaltyService, penaltyTypesReference, penaltyType]);
|
||||
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
|
||||
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !protest) return;
|
||||
if (penaltyTypesLoading) return;
|
||||
if (!decision || !stewardNotes.trim() || !detail) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
|
||||
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
|
||||
const protest = detail.protest;
|
||||
|
||||
const defaultUpheldReason = detail.defaultReasons?.upheld;
|
||||
const defaultDismissedReason = detail.defaultReasons?.dismissed;
|
||||
|
||||
if (decision === 'uphold') {
|
||||
const requiresValue = selectedPenalty?.requiresValue ?? true;
|
||||
@@ -287,7 +219,7 @@ export default function ProtestReviewPage() {
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
} else {
|
||||
const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
|
||||
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
|
||||
const requiresValue = warningRef?.requiresValue ?? false;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
@@ -330,12 +262,12 @@ export default function ProtestReviewPage() {
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!protest) return;
|
||||
if (!detail) return;
|
||||
|
||||
try {
|
||||
// Request defense
|
||||
await protestService.requestDefense({
|
||||
protestId: protest.id,
|
||||
protestId: detail.protest.id,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
|
||||
@@ -379,7 +311,7 @@ export default function ProtestReviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !protest || !race) {
|
||||
if (loading || !detail) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
@@ -389,6 +321,11 @@ export default function ProtestReviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const protest = detail.protest;
|
||||
const race = detail.race;
|
||||
const protestingDriver = detail.protestingDriver;
|
||||
const accusedDriver = detail.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
|
||||
@@ -5,7 +5,7 @@ import '@testing-library/jest-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import RaceDetailPage from './page';
|
||||
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
|
||||
import type { RaceDetailsViewModel } from '@/lib/view-models/RaceDetailsViewModel';
|
||||
|
||||
// Mocks for Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
@@ -40,7 +40,7 @@ vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
|
||||
}));
|
||||
|
||||
// Mock services hook to provide raceService and leagueMembershipService
|
||||
const mockGetRaceDetail = vi.fn();
|
||||
const mockGetRaceDetails = vi.fn();
|
||||
const mockReopenRace = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
@@ -48,7 +48,7 @@ const mockGetMembership = vi.fn();
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => ({
|
||||
raceService: {
|
||||
getRaceDetail: mockGetRaceDetail,
|
||||
getRaceDetails: mockGetRaceDetails,
|
||||
reopenRace: mockReopenRace,
|
||||
// other methods are not used in this test
|
||||
},
|
||||
@@ -79,8 +79,10 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
const createViewModel = (status: string) => {
|
||||
return new RaceDetailViewModel({
|
||||
const createViewModel = (status: string): RaceDetailsViewModel => {
|
||||
const canReopenRace = status === 'completed' || status === 'cancelled';
|
||||
|
||||
return {
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
@@ -88,10 +90,7 @@ const createViewModel = (status: string) => {
|
||||
scheduledAt: '2023-12-31T20:00:00Z',
|
||||
status,
|
||||
sessionType: 'race',
|
||||
strengthOfField: null,
|
||||
registeredCount: 0,
|
||||
maxParticipants: 32,
|
||||
} as any,
|
||||
},
|
||||
league: {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
@@ -100,19 +99,20 @@ const createViewModel = (status: string) => {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'open',
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isRegistered: false,
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
} as any,
|
||||
},
|
||||
userResult: null,
|
||||
}, 'driver-1');
|
||||
canReopenRace,
|
||||
};
|
||||
};
|
||||
|
||||
describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
beforeEach(() => {
|
||||
mockGetRaceDetail.mockReset();
|
||||
mockGetRaceDetails.mockReset();
|
||||
mockReopenRace.mockReset();
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
@@ -127,7 +127,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
// First call: initial load, second call: after re-open
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
|
||||
// loadRaceData should be called again after reopening
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
expect(mockGetRaceDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
@@ -156,12 +156,12 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
it('does not render Re-open Race button for non-admin viewer', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(false);
|
||||
const viewModel = createViewModel('completed');
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
|
||||
renderWithQueryClient(<RaceDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
expect(mockGetRaceDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
@@ -170,12 +170,12 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('scheduled');
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
|
||||
renderWithQueryClient(<RaceDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
expect(mockGetRaceDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
|
||||
@@ -278,7 +278,7 @@ export default function RaceDetailPage() {
|
||||
const entryList: RaceDetailEntryViewModel[] = viewModel.entryList;
|
||||
const registration = viewModel.registration;
|
||||
const userResult: RaceDetailUserResultViewModel | null = viewModel.userResult;
|
||||
const raceSOF = null; // TODO: Add strengthOfField to RaceDetailRaceDTO
|
||||
const raceSOF = null; // TODO: Add strength of field to race details response
|
||||
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
@@ -636,7 +636,7 @@ export default function RaceDetailPage() {
|
||||
{raceSOF ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
{/* TODO: Add registeredCount and maxParticipants to RaceDetailRaceDTO */}
|
||||
{/* TODO: Add registered count and max participants to race details response */}
|
||||
{/* {race.registeredCount !== undefined && (
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Registered</p>
|
||||
|
||||
79
apps/website/components/leagues/LeagueSchedule.test.tsx
Normal file
79
apps/website/components/leagues/LeagueSchedule.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import LeagueSchedule from './LeagueSchedule';
|
||||
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-123',
|
||||
}));
|
||||
|
||||
const mockUseLeagueSchedule = vi.fn();
|
||||
vi.mock('@/hooks/useLeagueService', () => ({
|
||||
useLeagueSchedule: (...args: unknown[]) => mockUseLeagueSchedule(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useRaceService', () => ({
|
||||
useRegisterForRace: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useWithdrawFromRace: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LeagueSchedule', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
mockPush.mockReset();
|
||||
mockUseLeagueSchedule.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a schedule race (no crash)', () => {
|
||||
mockUseLeagueSchedule.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: new LeagueScheduleViewModel([
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Round 1',
|
||||
scheduledAt: new Date('2025-01-02T20:00:00Z'),
|
||||
isPast: false,
|
||||
isUpcoming: true,
|
||||
status: 'scheduled',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
render(<LeagueSchedule leagueId="league-1" />);
|
||||
|
||||
expect(screen.getByText('Round 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state while schedule is loading', () => {
|
||||
mockUseLeagueSchedule.mockReturnValue({
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
render(<LeagueSchedule leagueId="league-1" />);
|
||||
|
||||
expect(screen.getByText('Loading schedule...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useLeagueSchedule } from '@/hooks/useLeagueService';
|
||||
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
|
||||
interface LeagueScheduleProps {
|
||||
leagueId: string;
|
||||
@@ -21,14 +22,13 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
const withdrawMutation = useWithdrawFromRace();
|
||||
|
||||
const races = useMemo(() => {
|
||||
// Current contract uses `unknown[]` for races; treat as any until a proper schedule DTO/view-model is introduced.
|
||||
return (schedule?.races ?? []) as Array<any>;
|
||||
return schedule?.races ?? [];
|
||||
}, [schedule]);
|
||||
|
||||
const handleRegister = async (race: any, e: React.MouseEvent) => {
|
||||
const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(`Register for ${race.track}?`);
|
||||
const confirmed = window.confirm(`Register for ${race.track ?? race.name}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (race: any, e: React.MouseEvent) => {
|
||||
const handleWithdraw = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm('Withdraw from this race?');
|
||||
@@ -134,6 +134,9 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
const isPast = race.isPast;
|
||||
const isUpcoming = race.isUpcoming;
|
||||
const isRegistered = Boolean(race.isRegistered);
|
||||
const trackLabel = race.track ?? race.name;
|
||||
const carLabel = race.car ?? '—';
|
||||
const sessionTypeLabel = (race.sessionType ?? 'race').toLowerCase();
|
||||
const isProcessing =
|
||||
registerMutation.isPending || withdrawMutation.isPending;
|
||||
|
||||
@@ -150,7 +153,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-white font-medium">{race.track}</h3>
|
||||
<h3 className="text-white font-medium">{trackLabel}</h3>
|
||||
{isUpcoming && !isRegistered && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Upcoming
|
||||
@@ -167,9 +170,9 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{race.car}</p>
|
||||
<p className="text-sm text-gray-400">{carLabel}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<p className="text-xs text-gray-500 uppercase">{race.sessionType}</p>
|
||||
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useRaceDetail(raceId: string, driverId: string) {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['raceDetail', raceId, driverId],
|
||||
queryFn: () => raceService.getRaceDetail(raceId, driverId),
|
||||
queryFn: () => raceService.getRaceDetails(raceId, driverId),
|
||||
enabled: !!raceId && !!driverId,
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function useRaceDetailMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ raceId, driverId }: { raceId: string; driverId: string }) =>
|
||||
raceService.getRaceDetail(raceId, driverId),
|
||||
raceService.getRaceDetails(raceId, driverId),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(['raceDetail', variables.raceId, variables.driverId], data);
|
||||
},
|
||||
|
||||
@@ -11,6 +11,17 @@ import type { RaceDTO } from '../../types/generated/RaceDTO';
|
||||
import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO';
|
||||
import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO';
|
||||
import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO';
|
||||
import type { CreateLeagueScheduleRaceInputDTO } from '../../types/generated/CreateLeagueScheduleRaceInputDTO';
|
||||
import type { CreateLeagueScheduleRaceOutputDTO } from '../../types/generated/CreateLeagueScheduleRaceOutputDTO';
|
||||
import type { UpdateLeagueScheduleRaceInputDTO } from '../../types/generated/UpdateLeagueScheduleRaceInputDTO';
|
||||
import type { LeagueScheduleRaceMutationSuccessDTO } from '../../types/generated/LeagueScheduleRaceMutationSuccessDTO';
|
||||
import type { LeagueSeasonSchedulePublishOutputDTO } from '../../types/generated/LeagueSeasonSchedulePublishOutputDTO';
|
||||
import type { LeagueRosterMemberDTO } from '../../types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueRosterJoinRequestDTO } from '../../types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { ApproveJoinRequestOutputDTO } from '../../types/generated/ApproveJoinRequestOutputDTO';
|
||||
import type { RejectJoinRequestOutputDTO } from '../../types/generated/RejectJoinRequestOutputDTO';
|
||||
import type { UpdateLeagueMemberRoleOutputDTO } from '../../types/generated/UpdateLeagueMemberRoleOutputDTO';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO';
|
||||
import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO';
|
||||
|
||||
/**
|
||||
@@ -40,8 +51,9 @@ export class LeaguesApiClient extends BaseApiClient {
|
||||
}
|
||||
|
||||
/** Get league schedule */
|
||||
getSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.get<LeagueScheduleDTO>(`/leagues/${leagueId}/schedule`);
|
||||
getSchedule(leagueId: string, seasonId?: string): Promise<LeagueScheduleDTO> {
|
||||
const qs = seasonId ? `?seasonId=${encodeURIComponent(seasonId)}` : '';
|
||||
return this.get<LeagueScheduleDTO>(`/leagues/${leagueId}/schedule${qs}`);
|
||||
}
|
||||
|
||||
/** Get league memberships */
|
||||
@@ -92,8 +104,78 @@ export class LeaguesApiClient extends BaseApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish a league season schedule (admin/owner only; actor derived from session) */
|
||||
publishSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.post<LeagueSeasonSchedulePublishOutputDTO>(`/leagues/${leagueId}/seasons/${seasonId}/schedule/publish`, {});
|
||||
}
|
||||
|
||||
/** Unpublish a league season schedule (admin/owner only; actor derived from session) */
|
||||
unpublishSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.post<LeagueSeasonSchedulePublishOutputDTO>(`/leagues/${leagueId}/seasons/${seasonId}/schedule/unpublish`, {});
|
||||
}
|
||||
|
||||
/** Create a schedule race for a league season (admin/owner only; actor derived from session) */
|
||||
createSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
input: CreateLeagueScheduleRaceInputDTO,
|
||||
): Promise<CreateLeagueScheduleRaceOutputDTO> {
|
||||
const { example: _example, ...payload } = input;
|
||||
return this.post<CreateLeagueScheduleRaceOutputDTO>(`/leagues/${leagueId}/seasons/${seasonId}/schedule/races`, payload);
|
||||
}
|
||||
|
||||
/** Update a schedule race for a league season (admin/owner only; actor derived from session) */
|
||||
updateSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: UpdateLeagueScheduleRaceInputDTO,
|
||||
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
const { example: _example, ...payload } = input;
|
||||
return this.patch<LeagueScheduleRaceMutationSuccessDTO>(`/leagues/${leagueId}/seasons/${seasonId}/schedule/races/${raceId}`, payload);
|
||||
}
|
||||
|
||||
/** Delete a schedule race for a league season (admin/owner only; actor derived from session) */
|
||||
deleteSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
return this.delete<LeagueScheduleRaceMutationSuccessDTO>(`/leagues/${leagueId}/seasons/${seasonId}/schedule/races/${raceId}`);
|
||||
}
|
||||
|
||||
/** Get races for a league */
|
||||
getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> {
|
||||
return this.get<{ races: RaceDTO[] }>(`/leagues/${leagueId}/races`);
|
||||
}
|
||||
|
||||
/** Admin roster: list current members (admin/owner only; actor derived from session) */
|
||||
getAdminRosterMembers(leagueId: string): Promise<LeagueRosterMemberDTO[]> {
|
||||
return this.get<LeagueRosterMemberDTO[]>(`/leagues/${leagueId}/admin/roster/members`);
|
||||
}
|
||||
|
||||
/** Admin roster: list pending join requests (admin/owner only; actor derived from session) */
|
||||
getAdminRosterJoinRequests(leagueId: string): Promise<LeagueRosterJoinRequestDTO[]> {
|
||||
return this.get<LeagueRosterJoinRequestDTO[]>(`/leagues/${leagueId}/admin/roster/join-requests`);
|
||||
}
|
||||
|
||||
/** Admin roster: approve a join request (admin/owner only; actor derived from session) */
|
||||
approveRosterJoinRequest(leagueId: string, joinRequestId: string): Promise<ApproveJoinRequestOutputDTO> {
|
||||
return this.post<ApproveJoinRequestOutputDTO>(`/leagues/${leagueId}/admin/roster/join-requests/${joinRequestId}/approve`, {});
|
||||
}
|
||||
|
||||
/** Admin roster: reject a join request (admin/owner only; actor derived from session) */
|
||||
rejectRosterJoinRequest(leagueId: string, joinRequestId: string): Promise<RejectJoinRequestOutputDTO> {
|
||||
return this.post<RejectJoinRequestOutputDTO>(`/leagues/${leagueId}/admin/roster/join-requests/${joinRequestId}/reject`, {});
|
||||
}
|
||||
|
||||
/** Admin roster: update member role (admin/owner only; actor derived from session) */
|
||||
updateRosterMemberRole(leagueId: string, targetDriverId: string, newRole: string): Promise<UpdateLeagueMemberRoleOutputDTO> {
|
||||
return this.patch<UpdateLeagueMemberRoleOutputDTO>(`/leagues/${leagueId}/admin/roster/members/${targetDriverId}/role`, { newRole });
|
||||
}
|
||||
|
||||
/** Admin roster: remove member (admin/owner only; actor derived from session) */
|
||||
removeRosterMember(leagueId: string, targetDriverId: string): Promise<RemoveLeagueMemberOutputDTO> {
|
||||
return this.patch<RemoveLeagueMemberOutputDTO>(`/leagues/${leagueId}/admin/roster/members/${targetDriverId}/remove`, {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export class ProtestDecisionCommandModel {
|
||||
raceId,
|
||||
driverId,
|
||||
stewardId,
|
||||
enum: this.penaltyType,
|
||||
type: this.penaltyType,
|
||||
reason,
|
||||
protestId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, Mocked, beforeEach, afterEach } from 'vitest';
|
||||
import { LeagueService } from './LeagueService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { LeagueStandingsViewModel } from '../../view-models/LeagueStandingsViewModel';
|
||||
@@ -114,12 +114,19 @@ describe('LeagueService', () => {
|
||||
});
|
||||
|
||||
describe('getLeagueSchedule', () => {
|
||||
it('should call apiClient.getSchedule and return LeagueScheduleViewModel', async () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call apiClient.getSchedule and return LeagueScheduleViewModel with Date parsing', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
{ id: 'race-1', name: 'Race One', date: new Date().toISOString() },
|
||||
{ id: 'race-2', name: 'Race Two', date: new Date().toISOString() },
|
||||
{ id: 'race-1', name: 'Race One', date: '2024-12-31T20:00:00Z' },
|
||||
{ id: 'race-2', name: 'Race Two', date: '2025-01-02T20:00:00Z' },
|
||||
],
|
||||
} as any;
|
||||
|
||||
@@ -129,14 +136,51 @@ describe('LeagueService', () => {
|
||||
|
||||
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
|
||||
expect(result).toBeInstanceOf(LeagueScheduleViewModel);
|
||||
expect(result.races).toEqual(mockDto.races);
|
||||
|
||||
expect(result.raceCount).toBe(2);
|
||||
expect(result.races[0]!.scheduledAt).toBeInstanceOf(Date);
|
||||
expect(result.races[0]!.isPast).toBe(true);
|
||||
expect(result.races[1]!.isUpcoming).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer scheduledAt over date and map optional fields/status', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Round 1',
|
||||
date: '2025-01-02T20:00:00Z',
|
||||
scheduledAt: '2025-01-03T20:00:00Z',
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
sessionType: 'race',
|
||||
isRegistered: true,
|
||||
status: 'scheduled',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
mockApiClient.getSchedule.mockResolvedValue(mockDto);
|
||||
|
||||
const result = await service.getLeagueSchedule(leagueId);
|
||||
|
||||
expect(result.races[0]!.scheduledAt.toISOString()).toBe('2025-01-03T20:00:00.000Z');
|
||||
expect(result.races[0]!.track).toBe('Monza');
|
||||
expect(result.races[0]!.car).toBe('GT3');
|
||||
expect(result.races[0]!.sessionType).toBe('race');
|
||||
expect(result.races[0]!.isRegistered).toBe(true);
|
||||
expect(result.races[0]!.status).toBe('scheduled');
|
||||
});
|
||||
|
||||
it('should handle empty races array', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = { races: [] };
|
||||
|
||||
mockApiClient.getSchedule.mockResolvedValue(mockDto);
|
||||
mockApiClient.getSchedule.mockResolvedValue(mockDto as any);
|
||||
|
||||
const result = await service.getLeagueSchedule(leagueId);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,10 +22,12 @@ describe('LeagueStewardingService', () => {
|
||||
|
||||
mockProtestService = {
|
||||
findByRaceId: vi.fn(),
|
||||
getProtestById: vi.fn(),
|
||||
} as Mocked<ProtestService>;
|
||||
|
||||
mockPenaltyService = {
|
||||
findByRaceId: vi.fn(),
|
||||
getPenaltyTypesReference: vi.fn(),
|
||||
} as Mocked<PenaltyService>;
|
||||
|
||||
mockDriverService = {
|
||||
@@ -144,4 +146,35 @@ describe('LeagueStewardingService', () => {
|
||||
expect(mockPenaltyService.applyPenalty).toHaveBeenCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProtestDetailViewModel', () => {
|
||||
it('should combine protest details + penalty types into a page-ready view model', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const protestId = 'protest-1';
|
||||
|
||||
mockProtestService.getProtestById.mockResolvedValue({
|
||||
protest: { id: protestId, raceId: 'race-1', protestingDriverId: 'd1', accusedDriverId: 'd2', status: 'pending', submittedAt: '2023-10-01T10:00:00Z', description: 'desc' } as any,
|
||||
race: { id: 'race-1' } as any,
|
||||
protestingDriver: { id: 'd1', name: 'Driver 1' } as any,
|
||||
accusedDriver: { id: 'd2', name: 'Driver 2' } as any,
|
||||
});
|
||||
|
||||
mockPenaltyService.getPenaltyTypesReference.mockResolvedValue({
|
||||
penaltyTypes: [
|
||||
{ type: 'time_penalty', requiresValue: true, valueKind: 'seconds' },
|
||||
{ type: 'warning', requiresValue: false, valueKind: 'none' },
|
||||
],
|
||||
defaultReasons: { upheld: 'Upheld reason', dismissed: 'Dismissed reason' },
|
||||
} as any);
|
||||
|
||||
const result = await service.getProtestDetailViewModel(leagueId, protestId);
|
||||
|
||||
expect(mockProtestService.getProtestById).toHaveBeenCalledWith(leagueId, protestId);
|
||||
expect(mockPenaltyService.getPenaltyTypesReference).toHaveBeenCalled();
|
||||
expect(result.protest.id).toBe(protestId);
|
||||
expect(result.penaltyTypes.length).toBe(2);
|
||||
expect(result.defaultReasons.upheld).toBe('Upheld reason');
|
||||
expect(result.initialPenaltyType).toBe('time_penalty');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { PenaltyService } from '../penalties/PenaltyService';
|
||||
import { DriverService } from '../drivers/DriverService';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel';
|
||||
import type { ProtestDetailViewModel } from '../../view-models/ProtestDetailViewModel';
|
||||
|
||||
/**
|
||||
* League Stewarding Service
|
||||
@@ -12,6 +13,39 @@ import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/L
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueStewardingService {
|
||||
private getPenaltyValueLabel(valueKind: string): string {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 'seconds';
|
||||
case 'grid_positions':
|
||||
return 'positions';
|
||||
case 'points':
|
||||
return 'points';
|
||||
case 'races':
|
||||
return 'races';
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getFallbackDefaultPenaltyValue(valueKind: string): number {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 5;
|
||||
case 'grid_positions':
|
||||
return 3;
|
||||
case 'points':
|
||||
return 5;
|
||||
case 'races':
|
||||
return 1;
|
||||
case 'none':
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
constructor(
|
||||
private readonly raceService: RaceService,
|
||||
private readonly protestService: ProtestService,
|
||||
@@ -77,6 +111,58 @@ export class LeagueStewardingService {
|
||||
return new LeagueStewardingViewModel(racesWithData, driverMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protest review details as a page-ready view model
|
||||
*/
|
||||
async getProtestDetailViewModel(leagueId: string, protestId: string): Promise<ProtestDetailViewModel> {
|
||||
const [protestData, penaltyTypesReference] = await Promise.all([
|
||||
this.protestService.getProtestById(leagueId, protestId),
|
||||
this.penaltyService.getPenaltyTypesReference(),
|
||||
]);
|
||||
|
||||
if (!protestData) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
const penaltyUiDefaults: Record<string, { label: string; description: string; defaultValue: number }> = {
|
||||
time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', defaultValue: 5 },
|
||||
grid_penalty: { label: 'Grid Penalty', description: 'Grid positions for next race', defaultValue: 3 },
|
||||
points_deduction: { label: 'Points Deduction', description: 'Deduct championship points', defaultValue: 5 },
|
||||
disqualification: { label: 'Disqualification', description: 'Disqualify from race', defaultValue: 0 },
|
||||
warning: { label: 'Warning', description: 'Official warning only', defaultValue: 0 },
|
||||
license_points: { label: 'License Points', description: 'Safety rating penalty', defaultValue: 2 },
|
||||
};
|
||||
|
||||
const penaltyTypes = (penaltyTypesReference?.penaltyTypes ?? []).map((ref: any) => {
|
||||
const ui = penaltyUiDefaults[ref.type];
|
||||
const valueLabel = this.getPenaltyValueLabel(String(ref.valueKind ?? 'none'));
|
||||
const defaultValue = ui?.defaultValue ?? this.getFallbackDefaultPenaltyValue(String(ref.valueKind ?? 'none'));
|
||||
|
||||
return {
|
||||
type: String(ref.type),
|
||||
label: ui?.label ?? String(ref.type).replaceAll('_', ' '),
|
||||
description: ui?.description ?? '',
|
||||
requiresValue: Boolean(ref.requiresValue),
|
||||
valueLabel,
|
||||
defaultValue,
|
||||
};
|
||||
});
|
||||
|
||||
const timePenalty = penaltyTypes.find((p) => p.type === 'time_penalty');
|
||||
const initial = timePenalty ?? penaltyTypes[0];
|
||||
|
||||
return {
|
||||
protest: protestData.protest,
|
||||
race: protestData.race,
|
||||
protestingDriver: protestData.protestingDriver,
|
||||
accusedDriver: protestData.accusedDriver,
|
||||
penaltyTypes,
|
||||
defaultReasons: penaltyTypesReference?.defaultReasons ?? { upheld: '', dismissed: '' },
|
||||
initialPenaltyType: initial?.type ?? null,
|
||||
initialPenaltyValue: initial?.defaultValue ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Review a protest
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
|
||||
describe('Website boundary: pages/components must not import mappers or generated DTOs', () => {
|
||||
it('rejects imports from mappers and types/generated', async () => {
|
||||
const websiteRoot = path.resolve(__dirname, '../../..');
|
||||
|
||||
const candidates = await glob([
|
||||
'app/leagues/[id]/schedule/**/*.{ts,tsx}',
|
||||
'components/leagues/LeagueSchedule.tsx',
|
||||
], {
|
||||
cwd: websiteRoot,
|
||||
absolute: true,
|
||||
nodir: true,
|
||||
ignore: ['**/*.test.*', '**/*.spec.*'],
|
||||
});
|
||||
|
||||
const forbiddenImportRegex =
|
||||
/^\s*import[\s\S]*from\s+['"][^'"]*(\/mappers\/|\/types\/generated\/)[^'"]*['"]/gm;
|
||||
|
||||
const offenders: Array<{ file: string; matches: string[] }> = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const content = await fs.readFile(file, 'utf-8');
|
||||
|
||||
const matches = Array.from(content.matchAll(forbiddenImportRegex)).map((m) => m[0]).filter(Boolean);
|
||||
|
||||
if (matches.length > 0) {
|
||||
offenders.push({
|
||||
file: path.relative(websiteRoot, file),
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, `Forbidden imports found:\n${offenders
|
||||
.map((o) => `- ${o.file}\n${o.matches.map((m) => ` ${m}`).join('\n')}`)
|
||||
.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Schedule boundary: view-model constructors must not depend on DTOs or mappers', () => {
|
||||
it('rejects schedule ViewModels importing from mappers or types/generated', async () => {
|
||||
const websiteRoot = path.resolve(__dirname, '../../..');
|
||||
|
||||
const viewModelFiles = [
|
||||
'lib/view-models/LeagueScheduleViewModel.ts',
|
||||
'lib/view-models/LeagueAdminScheduleViewModel.ts',
|
||||
];
|
||||
|
||||
const forbiddenImportRegex =
|
||||
/^\s*import[\s\S]*from\s+['"][^'"]*(\/mappers\/|\/types\/generated\/)[^'"]*['"]/gm;
|
||||
|
||||
const offenders: Array<{ file: string; matches: string[] }> = [];
|
||||
|
||||
for (const rel of viewModelFiles) {
|
||||
const abs = path.join(websiteRoot, rel);
|
||||
const content = await fs.readFile(abs, 'utf-8');
|
||||
|
||||
const matches = Array.from(content.matchAll(forbiddenImportRegex)).map((m) => m[0]).filter(Boolean);
|
||||
|
||||
if (matches.length > 0) {
|
||||
offenders.push({ file: rel, matches });
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, `Forbidden imports found:\n${offenders
|
||||
.map((o) => `- ${o.file}\n${o.matches.map((m) => ` ${m}`).join('\n')}`)
|
||||
.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Website boundary: pages must consume ViewModels only (no DTO imports)', () => {
|
||||
it('rejects forbidden imports for specific pages', async () => {
|
||||
const websiteRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
const candidates = [
|
||||
'app/races/[id]/page.tsx',
|
||||
'app/leagues/[id]/stewarding/protests/[protestId]/page.tsx',
|
||||
].map((p) => path.resolve(websiteRoot, p));
|
||||
|
||||
const forbiddenImportRegex =
|
||||
/^\s*import[\s\S]*from\s+['"][^'"]*(\/mappers\/|\/lib\/types\/|\/types\/generated\/)[^'"]*['"]/gm;
|
||||
|
||||
const offenders: Array<{ file: string; matches: string[] }> = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const content = await fs.readFile(file, 'utf-8');
|
||||
|
||||
const matches = Array.from(content.matchAll(forbiddenImportRegex))
|
||||
.map((m) => m[0])
|
||||
.filter(Boolean);
|
||||
|
||||
if (matches.length > 0) {
|
||||
offenders.push({
|
||||
file: path.relative(websiteRoot, file),
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`Forbidden imports found:\n${offenders
|
||||
.map((o) => `- ${o.file}\n${o.matches.map((m) => ` ${m}`).join('\n')}`)
|
||||
.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects DTO identifier usage in these page modules', async () => {
|
||||
const websiteRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
const candidates = [
|
||||
'app/races/[id]/page.tsx',
|
||||
'app/leagues/[id]/stewarding/protests/[protestId]/page.tsx',
|
||||
].map((p) => path.resolve(websiteRoot, p));
|
||||
|
||||
const dtoIdentifierRegex = /\b[A-Za-z0-9_]+DTO\b/g;
|
||||
|
||||
const offenders: Array<{ file: string; matches: string[] }> = [];
|
||||
|
||||
for (const file of candidates) {
|
||||
const content = await fs.readFile(file, 'utf-8');
|
||||
const matches = Array.from(content.matchAll(dtoIdentifierRegex)).map((m) => m[0]).filter(Boolean);
|
||||
|
||||
if (matches.length > 0) {
|
||||
offenders.push({
|
||||
file: path.relative(websiteRoot, file),
|
||||
matches: Array.from(new Set(matches)).sort(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`DTO identifiers found:\n${offenders
|
||||
.map((o) => `- ${o.file}\n${o.matches.map((m) => ` ${m}`).join('\n')}`)
|
||||
.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe('ProtestService', () => {
|
||||
const input = {
|
||||
protestId: 'protest-123',
|
||||
stewardId: 'steward-456',
|
||||
decision: 'upheld',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Test notes',
|
||||
};
|
||||
|
||||
@@ -170,10 +170,7 @@ describe('ProtestService', () => {
|
||||
|
||||
await service.reviewProtest(input);
|
||||
|
||||
expect(mockApiClient.reviewProtest).toHaveBeenCalledWith({
|
||||
...input,
|
||||
enum: 'uphold',
|
||||
});
|
||||
expect(mockApiClient.reviewProtest).toHaveBeenCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -84,15 +84,13 @@ export class ProtestService {
|
||||
* Review protest
|
||||
*/
|
||||
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<void> {
|
||||
const normalizedDecision = input.decision.toLowerCase();
|
||||
const enumValue: ReviewProtestCommandDTO['enum'] =
|
||||
normalizedDecision === 'uphold' || normalizedDecision === 'upheld' ? 'uphold' : 'dismiss';
|
||||
const normalizedDecision =
|
||||
input.decision.toLowerCase() === 'upheld' ? 'uphold' : input.decision.toLowerCase();
|
||||
|
||||
const command: ReviewProtestCommandDTO = {
|
||||
protestId: input.protestId,
|
||||
stewardId: input.stewardId,
|
||||
enum: enumValue,
|
||||
decision: input.decision,
|
||||
decision: normalizedDecision,
|
||||
decisionNotes: input.decisionNotes,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
||||
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
|
||||
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
|
||||
import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel';
|
||||
|
||||
describe('RaceService', () => {
|
||||
let mockApiClient: Mocked<RacesApiClient>;
|
||||
@@ -57,6 +58,38 @@ describe('RaceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRaceDetails', () => {
|
||||
it('should call apiClient.getDetail and return a ViewModel-shaped object (no DTOs)', async () => {
|
||||
const raceId = 'race-123';
|
||||
const driverId = 'driver-456';
|
||||
|
||||
const mockDto = {
|
||||
race: {
|
||||
id: raceId,
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2023-12-31T20:00:00Z',
|
||||
status: 'completed',
|
||||
sessionType: 'race',
|
||||
},
|
||||
league: { id: 'league-1', name: 'Test League', description: 'Desc', settings: { maxDrivers: 32 } },
|
||||
entryList: [],
|
||||
registration: { isUserRegistered: true, canRegister: false },
|
||||
userResult: null,
|
||||
};
|
||||
|
||||
mockApiClient.getDetail.mockResolvedValue(mockDto as any);
|
||||
|
||||
const result: RaceDetailsViewModel = await service.getRaceDetails(raceId, driverId);
|
||||
|
||||
expect(mockApiClient.getDetail).toHaveBeenCalledWith(raceId, driverId);
|
||||
expect(result.race?.id).toBe(raceId);
|
||||
expect(result.league?.id).toBe('league-1');
|
||||
expect(result.registration.isUserRegistered).toBe(true);
|
||||
expect(result.canReopenRace).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRacesPageData', () => {
|
||||
it('should call apiClient.getPageData and return RacesPageViewModel with transformed data', async () => {
|
||||
const mockDto = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceDetailEntryViewModel } from '../../view-models/RaceDetailEntryViewModel';
|
||||
import { RaceDetailUserResultViewModel } from '../../view-models/RaceDetailUserResultViewModel';
|
||||
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
||||
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
|
||||
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
|
||||
import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel';
|
||||
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
|
||||
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
|
||||
/**
|
||||
@@ -26,6 +29,55 @@ export class RaceService {
|
||||
return new RaceDetailViewModel(dto, driverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race details for pages/components (DTO-free shape)
|
||||
*/
|
||||
async getRaceDetails(
|
||||
raceId: string,
|
||||
driverId: string
|
||||
): Promise<RaceDetailsViewModel> {
|
||||
const dto: any = await this.apiClient.getDetail(raceId, driverId);
|
||||
|
||||
const raceDto: any = dto?.race ?? null;
|
||||
const leagueDto: any = dto?.league ?? null;
|
||||
|
||||
const registrationDto: any = dto?.registration ?? {};
|
||||
const isUserRegistered = Boolean(registrationDto.isUserRegistered ?? registrationDto.isRegistered ?? false);
|
||||
const canRegister = Boolean(registrationDto.canRegister);
|
||||
|
||||
const status = String(raceDto?.status ?? '');
|
||||
const canReopenRace = status === 'completed' || status === 'cancelled';
|
||||
|
||||
return {
|
||||
race: raceDto
|
||||
? {
|
||||
id: String(raceDto.id ?? ''),
|
||||
track: String(raceDto.track ?? ''),
|
||||
car: String(raceDto.car ?? ''),
|
||||
scheduledAt: String(raceDto.scheduledAt ?? ''),
|
||||
status,
|
||||
sessionType: String(raceDto.sessionType ?? ''),
|
||||
}
|
||||
: null,
|
||||
league: leagueDto
|
||||
? {
|
||||
id: String(leagueDto.id ?? ''),
|
||||
name: String(leagueDto.name ?? ''),
|
||||
description: leagueDto.description ?? null,
|
||||
settings: leagueDto.settings,
|
||||
}
|
||||
: null,
|
||||
entryList: (dto?.entryList ?? []).map((entry: any) => new RaceDetailEntryViewModel(entry, driverId)),
|
||||
registration: {
|
||||
canRegister,
|
||||
isUserRegistered,
|
||||
},
|
||||
userResult: dto?.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null,
|
||||
canReopenRace,
|
||||
error: dto?.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get races page data with view model transformation
|
||||
*/
|
||||
|
||||
@@ -61,12 +61,19 @@ describe('Website Contract Consumption', () => {
|
||||
|
||||
for (const file of dtos) {
|
||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||
|
||||
// Basic syntax validation
|
||||
|
||||
// `index.ts` is a generated barrel file (no interfaces).
|
||||
if (file === 'index.ts') {
|
||||
expect(content).toContain('export type {');
|
||||
expect(content).toContain("from './");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic syntax validation (DTO interfaces)
|
||||
expect(content).toContain('export interface');
|
||||
expect(content).toContain('{');
|
||||
expect(content).toContain('}');
|
||||
|
||||
|
||||
// Should not have common syntax errors
|
||||
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AcceptSponsorshipRequestInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface ActivityItemDTO {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { LeagueSummaryDTO } from './LeagueSummaryDTO';
|
||||
import type { LeagueWithCapacityAndScoringDTO } from './LeagueWithCapacityAndScoringDTO';
|
||||
|
||||
export interface AllLeaguesWithCapacityAndScoringDTO {
|
||||
leagues: LeagueSummaryDTO[];
|
||||
leagues: LeagueWithCapacityAndScoringDTO[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { LeagueWithCapacityDTO } from './LeagueWithCapacityDTO';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { AllRacesStatusFilterDTO } from './AllRacesStatusFilterDTO';
|
||||
import type { AllRacesLeagueFilterDTO } from './AllRacesLeagueFilterDTO';
|
||||
import type { AllRacesStatusFilterDTO } from './AllRacesStatusFilterDTO';
|
||||
|
||||
export interface AllRacesFilterOptionsDTO {
|
||||
statuses: AllRacesStatusFilterDTO[];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AllRacesLeagueFilterDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AllRacesListItemDTO {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { AllRacesListItemDTO } from './AllRacesListItemDTO';
|
||||
import type { AllRacesFilterOptionsDTO } from './AllRacesFilterOptionsDTO';
|
||||
import type { AllRacesListItemDTO } from './AllRacesListItemDTO';
|
||||
|
||||
export interface AllRacesPageDTO {
|
||||
races: AllRacesListItemDTO[];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AllRacesStatusFilterDTO {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface ApplyPenaltyCommandDTO {
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
stewardId: string;
|
||||
enum: string;
|
||||
type: string;
|
||||
value?: number;
|
||||
reason: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface ApproveJoinRequestInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface ApproveJoinRequestOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { AuthenticatedUserDTO } from './AuthenticatedUserDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AuthenticatedUserDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AvailableLeagueDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface AvatarDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { PrizeDTO } from './PrizeDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface BillingStatsDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CompleteOnboardingInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CompleteOnboardingOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateLeagueInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateLeagueOutputDTO {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateLeagueScheduleRaceInputDTO {
|
||||
track: string;
|
||||
car: string;
|
||||
example: string;
|
||||
scheduledAtIso: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateLeagueScheduleRaceOutputDTO {
|
||||
raceId: string;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreatePaymentInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { PaymentDTO } from './PaymentDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { PrizeDTO } from './PrizeDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateSponsorInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { SponsorDTO } from './SponsorDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateTeamInputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface CreateTeamOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardDriverSummaryDTO {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardFeedItemSummaryDTO {
|
||||
id: string;
|
||||
enum: string;
|
||||
type: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DashboardFeedItemSummaryDTO } from './DashboardFeedItemSummaryDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardFriendSummaryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardLeagueStandingSummaryDTO {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DashboardDriverSummaryDTO } from './DashboardDriverSummaryDTO';
|
||||
import type { DashboardRaceSummaryDTO } from './DashboardRaceSummaryDTO';
|
||||
import type { DashboardRecentResultDTO } from './DashboardRecentResultDTO';
|
||||
import type { DashboardLeagueStandingSummaryDTO } from './DashboardLeagueStandingSummaryDTO';
|
||||
import type { DashboardFeedSummaryDTO } from './DashboardFeedSummaryDTO';
|
||||
import type { DashboardFriendSummaryDTO } from './DashboardFriendSummaryDTO';
|
||||
import type { DashboardLeagueStandingSummaryDTO } from './DashboardLeagueStandingSummaryDTO';
|
||||
import type { DashboardRaceSummaryDTO } from './DashboardRaceSummaryDTO';
|
||||
import type { DashboardRecentResultDTO } from './DashboardRecentResultDTO';
|
||||
|
||||
export interface DashboardOverviewDTO {
|
||||
currentDriver?: DashboardDriverSummaryDTO;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardRaceSummaryDTO {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DashboardRecentResultDTO {
|
||||
raceId: string;
|
||||
raceName: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
finishedAt: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DeleteMediaOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DeletePrizeResultDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverLeaderboardItemDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileAchievementDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileDriverSummaryDTO {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DriverProfileSocialHandleDTO } from './DriverProfileSocialHandleDTO';
|
||||
import type { DriverProfileAchievementDTO } from './DriverProfileAchievementDTO';
|
||||
import type { DriverProfileSocialHandleDTO } from './DriverProfileSocialHandleDTO';
|
||||
|
||||
export interface DriverProfileExtendedProfileDTO {
|
||||
socialHandles: DriverProfileSocialHandleDTO[];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileFinishDistributionDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileSocialFriendSummaryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileSocialHandleDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DriverProfileSocialFriendSummaryDTO } from './DriverProfileSocialFriendSummaryDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileStatsDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverProfileTeamMembershipDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverRegistrationStatusDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverStatsDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface DriverSummaryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DriverLeaderboardItemDTO } from './DriverLeaderboardItemDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { ProtestIncidentDTO } from './ProtestIncidentDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface FullTransactionDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { TeamListItemDTO } from './TeamListItemDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetAnalyticsMetricsOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetAvatarOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetDashboardDataOutputDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetDriverOutputDTO {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { DriverProfileDriverSummaryDTO } from './DriverProfileDriverSummaryDTO';
|
||||
import type { DriverProfileStatsDTO } from './DriverProfileStatsDTO';
|
||||
import type { DriverProfileFinishDistributionDTO } from './DriverProfileFinishDistributionDTO';
|
||||
import type { DriverProfileTeamMembershipDTO } from './DriverProfileTeamMembershipDTO';
|
||||
import type { DriverProfileSocialSummaryDTO } from './DriverProfileSocialSummaryDTO';
|
||||
import type { DriverProfileExtendedProfileDTO } from './DriverProfileExtendedProfileDTO';
|
||||
import type { DriverProfileFinishDistributionDTO } from './DriverProfileFinishDistributionDTO';
|
||||
import type { DriverProfileSocialSummaryDTO } from './DriverProfileSocialSummaryDTO';
|
||||
import type { DriverProfileStatsDTO } from './DriverProfileStatsDTO';
|
||||
import type { DriverProfileTeamMembershipDTO } from './DriverProfileTeamMembershipDTO';
|
||||
|
||||
export interface GetDriverProfileOutputDTO {
|
||||
currentDriver?: DriverProfileDriverSummaryDTO;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetDriverRegistrationStatusQueryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { TeamDTO } from './TeamDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { SponsorshipPricingItemDTO } from './SponsorshipPricingItemDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
import type { LeagueConfigFormModelDTO } from './LeagueConfigFormModelDTO';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetLeagueAdminConfigQueryDTO {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetLeagueAdminPermissionsInputDTO {
|
||||
leagueId: string;
|
||||
performerDriverId: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetLeagueJoinRequestsQueryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetLeagueOwnerSummaryQueryDTO {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Auto-generated DTO from OpenAPI spec
|
||||
* Spec SHA256: 16aae91ec085d450f377d8c914d93df23e86783b2b124fedd1075307d2c5b17f
|
||||
* This file is generated by scripts/generate-api-types.ts
|
||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||
* Do not edit manually - regenerate using: npm run api:generate-types
|
||||
*/
|
||||
|
||||
export interface GetLeagueProtestsQueryDTO {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user