wip league admin tools

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"test": "vitest run --config vitest.api.config.ts --root ../..",
"test:coverage": "vitest run --config vitest.api.config.ts --root ../.. --coverage",
"test:watch": "vitest --config vitest.api.config.ts --root ../..",
"generate:openapi": "GENERATE_OPENAPI=true ts-node src/main.ts --exit"
"generate:openapi": "cd ../.. && npm run api:generate-spec"
},
"keywords": [],
"author": "",

View File

@@ -181,7 +181,7 @@ describe('AnalyticsController', () => {
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
it('allows @Public() endpoint without a session', { retry: 2 }, async () => {
await request(app.getHttpServer()).post('/analytics/page-view').send({}).expect(201);
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { Result } from '@core/shared/application/Result';
import { getActorFromRequestContext } from './getActorFromRequestContext';
import { requireLeagueAdminOrOwner } from '../league/LeagueAuthorization';
async function withRequestContext<T>(req: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
const res = {};
return await new Promise<T>((resolve, reject) => {
requestContextMiddleware(req as any, res as any, () => {
fn().then(resolve, reject);
});
});
}
describe('ActorFromSession', () => {
it('derives actor from authenticated session (request.user), not request payload', async () => {
const req: any = {
user: { userId: 'driver-from-session' },
body: { driverId: 'driver-from-body' },
};
await withRequestContext(req, async () => {
const actor = getActorFromRequestContext();
expect(actor).toEqual({ userId: 'driver-from-session', driverId: 'driver-from-session' });
});
});
it('permission helper invokes league admin check using session-derived actor (ignores payload)', async () => {
const getLeagueAdminPermissionsUseCase = {
execute: vi.fn(async () => Result.ok(undefined)),
};
const req: any = {
user: { userId: 'driver-from-session' },
body: { performerDriverId: 'driver-from-body' },
};
await withRequestContext(req, async () => {
await expect(
requireLeagueAdminOrOwner('league-1', getLeagueAdminPermissionsUseCase as any),
).resolves.toBeUndefined();
});
expect(getLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
leagueId: 'league-1',
performerDriverId: 'driver-from-session',
});
});
it('permission helper rejects when league admin check fails', async () => {
const getLeagueAdminPermissionsUseCase = {
execute: vi.fn(async () =>
Result.err({ code: 'USER_NOT_MEMBER', details: { message: 'nope' } } as any),
),
};
const req: any = { user: { userId: 'driver-from-session' } };
await withRequestContext(req, async () => {
await expect(
requireLeagueAdminOrOwner('league-1', getLeagueAdminPermissionsUseCase as any),
).rejects.toThrow('Forbidden');
});
});
});

View File

@@ -0,0 +1,25 @@
import { getHttpRequestContext } from '@adapters/http/RequestContext';
export type Actor = {
userId: string;
driverId: string;
};
type AuthenticatedRequest = {
user?: { userId: string };
};
export function getActorFromRequestContext(): Actor {
const ctx = getHttpRequestContext();
const req = ctx.req as unknown as AuthenticatedRequest;
const userId = req.user?.userId;
if (!userId) {
throw new Error('Unauthorized');
}
// Current canonical mapping:
// - The authenticated session identity is `userId`.
// - In the current system, that `userId` is also treated as the performer `driverId`.
return { userId, driverId: userId };
}

View File

@@ -39,7 +39,7 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
track: String(raceSummary.race.track),
car: String(raceSummary.race.car),
scheduledAt: raceSummary.race.scheduledAt.toISOString(),
status: raceSummary.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
status: raceSummary.race.status.toString(),
isMyLeague: raceSummary.isMyLeague,
});

View File

@@ -0,0 +1,21 @@
import { ForbiddenException } from '@nestjs/common';
import { getActorFromRequestContext } from '../auth/getActorFromRequestContext';
import type { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
type GetLeagueAdminPermissionsUseCaseLike = Pick<GetLeagueAdminPermissionsUseCase, 'execute'>;
export async function requireLeagueAdminOrOwner(
leagueId: string,
getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCaseLike,
): Promise<void> {
const actor = getActorFromRequestContext();
const permissionResult = await getLeagueAdminPermissionsUseCase.execute({
leagueId,
performerDriverId: actor.driverId,
});
if (permissionResult.isErr()) {
throw new ForbiddenException('Forbidden');
}
}

View File

@@ -11,6 +11,7 @@ import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import { createHttpContractHarness } from '../../shared/testing/httpContractHarness';
describe('LeagueController', () => {
let controller: LeagueController;
@@ -135,4 +136,26 @@ describe('LeagueController', () => {
await request(app.getHttpServer()).get('/leagues/l1/admin').expect(200);
});
});
describe('transfer ownership contract (HTTP)', () => {
it('rejects client-supplied currentOwnerId (400) once DTO whitelisting is enforced', async () => {
const leagueService = {
transferLeagueOwnership: vi.fn(async () => ({ success: true })),
};
const harness = await createHttpContractHarness({
controllers: [LeagueController],
providers: [{ provide: LeagueService, useValue: leagueService }],
});
try {
await harness.http
.post('/leagues/l1/transfer-ownership')
.send({ currentOwnerId: 'spoof', newOwnerId: 'o2' })
.expect(400);
} finally {
await harness.close();
}
});
});
});

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Inject, ValidationPipe, Query } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService';
@@ -16,14 +16,25 @@ import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueRosterJoinRequestDTO } from './dtos/LeagueRosterJoinRequestDTO';
import { LeagueRosterMemberDTO } from './dtos/LeagueRosterMemberDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import {
LeagueSeasonSchedulePublishInputDTO,
LeagueSeasonSchedulePublishOutputDTO,
} from './dtos/LeagueSeasonSchedulePublishDTO';
import {
CreateLeagueScheduleRaceInputDTO,
CreateLeagueScheduleRaceOutputDTO,
LeagueScheduleRaceMutationSuccessDTO,
UpdateLeagueScheduleRaceInputDTO,
} from './dtos/LeagueScheduleRaceAdminDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { RejectJoinRequestInputDTO } from './dtos/RejectJoinRequestInputDTO';
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
@@ -33,9 +44,11 @@ import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO';
import { GetLeagueScheduleQueryDTO } from './dtos/GetLeagueScheduleQueryDTO';
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO';
import { TransferLeagueOwnershipInputDTO } from './dtos/TransferLeagueOwnershipInputDTO';
@ApiTags('leagues')
@Controller('leagues')
@@ -103,24 +116,22 @@ export class LeagueController {
@ApiResponse({ status: 200, description: 'League admin permissions', type: LeagueAdminPermissionsDTO })
async getLeagueAdminPermissions(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('performerDriverId') _performerDriverId: string,
): Promise<LeagueAdminPermissionsDTO> {
// No specific input DTO needed for Get, parameters from path
return this.leagueService.getLeagueAdminPermissions({ leagueId, performerDriverId });
void _performerDriverId;
return this.leagueService.getLeagueAdminPermissions({ leagueId });
}
@Patch(':leagueId/members/:targetDriverId/remove')
@ApiOperation({ summary: 'Remove a member from the league' })
@ApiBody({ type: RemoveLeagueMemberInputDTO }) // Explicitly define body type for Swagger
@ApiResponse({ status: 200, description: 'Member removed successfully', type: RemoveLeagueMemberOutputDTO })
@ApiResponse({ status: 400, description: 'Cannot remove member' })
@ApiResponse({ status: 404, description: 'Member not found' })
async removeLeagueMember(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('targetDriverId') targetDriverId: string, // Body content for a patch often includes IDs
@Param('targetDriverId') targetDriverId: string,
): Promise<RemoveLeagueMemberOutputDTO> {
return this.leagueService.removeLeagueMember({ leagueId, performerDriverId, targetDriverId });
return this.leagueService.removeLeagueMember({ leagueId, targetDriverId });
}
@Patch(':leagueId/members/:targetDriverId/role')
@@ -131,11 +142,10 @@ export class LeagueController {
@ApiResponse({ status: 404, description: 'Member not found' })
async updateLeagueMemberRole(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('targetDriverId') targetDriverId: string,
@Body() input: UpdateLeagueMemberRoleInputDTO, // Body includes newRole, other for swagger
): Promise<UpdateLeagueMemberRoleOutputDTO> {
return this.leagueService.updateLeagueMemberRole({ leagueId, performerDriverId, targetDriverId, newRole: input.newRole });
return this.leagueService.updateLeagueMemberRole(leagueId, targetDriverId, input);
}
@Public()
@@ -229,8 +239,105 @@ export class LeagueController {
@Get(':leagueId/schedule')
@ApiOperation({ summary: 'Get league schedule' })
@ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleDTO })
async getLeagueSchedule(@Param('leagueId') leagueId: string): Promise<LeagueScheduleDTO> {
return this.leagueService.getLeagueSchedule(leagueId);
async getLeagueSchedule(
@Param('leagueId') leagueId: string,
@Query() query: GetLeagueScheduleQueryDTO,
): Promise<LeagueScheduleDTO> {
return this.leagueService.getLeagueSchedule(leagueId, query);
}
@Post(':leagueId/seasons/:seasonId/schedule/publish')
@HttpCode(200)
@ApiOperation({ summary: 'Publish a league season schedule (admin/owner only; actor derived from session)' })
@ApiBody({ type: LeagueSeasonSchedulePublishInputDTO })
@ApiResponse({ status: 200, description: 'Schedule published', type: LeagueSeasonSchedulePublishOutputDTO })
async publishLeagueSeasonSchedule(
@Param('leagueId') leagueId: string,
@Param('seasonId') seasonId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: LeagueSeasonSchedulePublishInputDTO,
}),
)
input: LeagueSeasonSchedulePublishInputDTO,
): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.leagueService.publishLeagueSeasonSchedule(leagueId, seasonId, input);
}
@Post(':leagueId/seasons/:seasonId/schedule/unpublish')
@HttpCode(200)
@ApiOperation({ summary: 'Unpublish a league season schedule (admin/owner only; actor derived from session)' })
@ApiBody({ type: LeagueSeasonSchedulePublishInputDTO })
@ApiResponse({ status: 200, description: 'Schedule unpublished', type: LeagueSeasonSchedulePublishOutputDTO })
async unpublishLeagueSeasonSchedule(
@Param('leagueId') leagueId: string,
@Param('seasonId') seasonId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: LeagueSeasonSchedulePublishInputDTO,
}),
)
input: LeagueSeasonSchedulePublishInputDTO,
): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.leagueService.unpublishLeagueSeasonSchedule(leagueId, seasonId, input);
}
@Post(':leagueId/seasons/:seasonId/schedule/races')
@ApiOperation({ summary: 'Create a schedule race for a league season (admin/owner only; actor derived from session)' })
@ApiBody({ type: CreateLeagueScheduleRaceInputDTO })
@ApiResponse({ status: 201, description: 'Race created', type: CreateLeagueScheduleRaceOutputDTO })
async createLeagueSeasonScheduleRace(
@Param('leagueId') leagueId: string,
@Param('seasonId') seasonId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: CreateLeagueScheduleRaceInputDTO,
}),
)
input: CreateLeagueScheduleRaceInputDTO,
): Promise<CreateLeagueScheduleRaceOutputDTO> {
return this.leagueService.createLeagueSeasonScheduleRace(leagueId, seasonId, input);
}
@Patch(':leagueId/seasons/:seasonId/schedule/races/:raceId')
@ApiOperation({ summary: 'Update a schedule race for a league season (admin/owner only; actor derived from session)' })
@ApiBody({ type: UpdateLeagueScheduleRaceInputDTO })
@ApiResponse({ status: 200, description: 'Race updated', type: LeagueScheduleRaceMutationSuccessDTO })
async updateLeagueSeasonScheduleRace(
@Param('leagueId') leagueId: string,
@Param('seasonId') seasonId: string,
@Param('raceId') raceId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: UpdateLeagueScheduleRaceInputDTO,
}),
)
input: UpdateLeagueScheduleRaceInputDTO,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
return this.leagueService.updateLeagueSeasonScheduleRace(leagueId, seasonId, raceId, input);
}
@Delete(':leagueId/seasons/:seasonId/schedule/races/:raceId')
@ApiOperation({ summary: 'Delete a schedule race for a league season (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'Race deleted', type: LeagueScheduleRaceMutationSuccessDTO })
async deleteLeagueSeasonScheduleRace(
@Param('leagueId') leagueId: string,
@Param('seasonId') seasonId: string,
@Param('raceId') raceId: string,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
return this.leagueService.deleteLeagueSeasonScheduleRace(leagueId, seasonId, raceId);
}
@Public()
@@ -241,6 +348,82 @@ export class LeagueController {
return this.leagueService.getLeagueStats(leagueId);
}
@Get(':leagueId/admin/roster/members')
@ApiOperation({ summary: 'Get league roster members (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'List of league roster members', type: [LeagueRosterMemberDTO] })
async getLeagueRosterMembers(@Param('leagueId') leagueId: string): Promise<LeagueRosterMemberDTO[]> {
return this.leagueService.getLeagueRosterMembers(leagueId);
}
@Patch(':leagueId/admin/roster/members/:targetDriverId/role')
@HttpCode(200)
@ApiOperation({ summary: "Change a roster member's role (admin/owner only; actor derived from session)" })
@ApiBody({ type: UpdateLeagueMemberRoleInputDTO })
@ApiResponse({ status: 200, description: 'Member role updated successfully', type: UpdateLeagueMemberRoleOutputDTO })
@ApiResponse({ status: 400, description: 'Cannot update role' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Member not found' })
async updateLeagueRosterMemberRole(
@Param('leagueId') leagueId: string,
@Param('targetDriverId') targetDriverId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: UpdateLeagueMemberRoleInputDTO,
}),
)
input: UpdateLeagueMemberRoleInputDTO,
): Promise<UpdateLeagueMemberRoleOutputDTO> {
return this.leagueService.updateLeagueMemberRole(leagueId, targetDriverId, input);
}
@Patch(':leagueId/admin/roster/members/:targetDriverId/remove')
@HttpCode(200)
@ApiOperation({ summary: 'Remove a roster member (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'Member removed successfully', type: RemoveLeagueMemberOutputDTO })
@ApiResponse({ status: 400, description: 'Cannot remove member' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@ApiResponse({ status: 404, description: 'Member not found' })
async removeLeagueRosterMember(
@Param('leagueId') leagueId: string,
@Param('targetDriverId') targetDriverId: string,
): Promise<RemoveLeagueMemberOutputDTO> {
return this.leagueService.removeLeagueMember({ leagueId, targetDriverId });
}
@Get(':leagueId/admin/roster/join-requests')
@ApiOperation({ summary: 'Get league roster join requests (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'List of league join requests', type: [LeagueRosterJoinRequestDTO] })
async getLeagueRosterJoinRequests(@Param('leagueId') leagueId: string): Promise<LeagueRosterJoinRequestDTO[]> {
return this.leagueService.getLeagueRosterJoinRequests(leagueId);
}
@Post(':leagueId/admin/roster/join-requests/:joinRequestId/approve')
@HttpCode(200)
@ApiOperation({ summary: 'Approve a league roster join request (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'Join request approved', type: ApproveJoinRequestOutputDTO })
@ApiResponse({ status: 404, description: 'Join request not found' })
async approveLeagueRosterJoinRequest(
@Param('leagueId') leagueId: string,
@Param('joinRequestId') joinRequestId: string,
): Promise<ApproveJoinRequestOutputDTO> {
return this.leagueService.approveLeagueRosterJoinRequest(leagueId, joinRequestId);
}
@Post(':leagueId/admin/roster/join-requests/:joinRequestId/reject')
@HttpCode(200)
@ApiOperation({ summary: 'Reject a league roster join request (admin/owner only; actor derived from session)' })
@ApiResponse({ status: 200, description: 'Join request rejected', type: RejectJoinRequestOutputDTO })
@ApiResponse({ status: 404, description: 'Join request not found' })
async rejectLeagueRosterJoinRequest(
@Param('leagueId') leagueId: string,
@Param('joinRequestId') joinRequestId: string,
): Promise<RejectJoinRequestOutputDTO> {
return this.leagueService.rejectLeagueRosterJoinRequest(leagueId, joinRequestId);
}
@Get(':leagueId/admin')
@ApiOperation({ summary: 'Get league admin data' })
@ApiResponse({ status: 200, description: 'League admin data', type: LeagueAdminDTO })
@@ -273,17 +456,29 @@ export class LeagueController {
}
@Post(':leagueId/join')
@ApiOperation({ summary: 'Join a league' })
@ApiOperation({ summary: 'Join a league (actor derived from session)' })
@ApiResponse({ status: 200, description: 'Joined league successfully' })
async joinLeague(@Param('leagueId') leagueId: string, @Body() body: { driverId: string }) {
return this.leagueService.joinLeague(leagueId, body.driverId);
async joinLeague(@Param('leagueId') leagueId: string) {
return this.leagueService.joinLeague(leagueId);
}
@Post(':leagueId/transfer-ownership')
@ApiOperation({ summary: 'Transfer league ownership' })
@ApiBody({ type: TransferLeagueOwnershipInputDTO })
@ApiResponse({ status: 200, description: 'Ownership transferred successfully' })
async transferLeagueOwnership(@Param('leagueId') leagueId: string, @Body() body: { currentOwnerId: string, newOwnerId: string }) {
return this.leagueService.transferLeagueOwnership(leagueId, body.currentOwnerId, body.newOwnerId);
async transferLeagueOwnership(
@Param('leagueId') leagueId: string,
@Body(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
expectedType: TransferLeagueOwnershipInputDTO,
}),
)
input: TransferLeagueOwnershipInputDTO,
) {
return this.leagueService.transferLeagueOwnership(leagueId, input);
}
@Public()

View File

@@ -1,5 +1,7 @@
import { Provider } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { LeagueService } from './LeagueService';
import * as LeagueTokens from './LeagueTokens';
// Import core interfaces
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
@@ -28,6 +30,8 @@ import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-c
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueRosterMembersUseCase } from '@core/racing/application/use-cases/GetLeagueRosterMembersUseCase';
import { GetLeagueRosterJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueRosterJoinRequestsUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
@@ -47,6 +51,13 @@ import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cas
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
// Schedule mutation use cases
import { CreateLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase';
import { UpdateLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase';
import { DeleteLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase';
import { PublishLeagueSeasonScheduleUseCase } from '@core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase';
import { UnpublishLeagueSeasonScheduleUseCase } from '@core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase';
// Import presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter';
@@ -54,6 +65,10 @@ import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoi
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import {
GetLeagueRosterJoinRequestsPresenter,
GetLeagueRosterMembersPresenter,
} from './presenters/LeagueRosterAdminReadPresenters';
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
@@ -74,6 +89,14 @@ import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwn
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { WithdrawFromLeagueWalletPresenter } from './presenters/WithdrawFromLeagueWalletPresenter';
import {
CreateLeagueSeasonScheduleRacePresenter,
DeleteLeagueSeasonScheduleRacePresenter,
PublishLeagueSeasonSchedulePresenter,
UnpublishLeagueSeasonSchedulePresenter,
UpdateLeagueSeasonScheduleRacePresenter,
} from './presenters/LeagueSeasonScheduleMutationPresenters';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = 'ILeagueStandingsRepository';
@@ -108,6 +131,8 @@ export const GET_LEAGUE_OWNER_SUMMARY_USE_CASE = 'GetLeagueOwnerSummaryUseCase';
export const GET_LEAGUE_PROTESTS_USE_CASE = 'GetLeagueProtestsUseCase';
export const GET_LEAGUE_SEASONS_USE_CASE = 'GetLeagueSeasonsUseCase';
export const GET_LEAGUE_MEMBERSHIPS_USE_CASE = 'GetLeagueMembershipsUseCase';
export const GET_LEAGUE_ROSTER_MEMBERS_USE_CASE = 'GetLeagueRosterMembersUseCase';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE = 'GetLeagueRosterJoinRequestsUseCase';
export const GET_LEAGUE_SCHEDULE_USE_CASE = 'GetLeagueScheduleUseCase';
export const GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE = 'GetLeagueAdminPermissionsUseCase';
export const GET_LEAGUE_WALLET_USE_CASE = 'GetLeagueWalletUseCase';
@@ -124,6 +149,8 @@ export const APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'ApproveLeagueJoinR
export const CREATE_LEAGUE_OUTPUT_PORT_TOKEN = 'CreateLeagueOutputPort_TOKEN';
export const GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN = 'GetLeagueAdminPermissionsOutputPort_TOKEN';
export const GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN = 'GetLeagueMembershipsOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterMembersOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterJoinRequestsOutputPort_TOKEN';
export const GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN = 'GetLeagueOwnerSummaryOutputPort_TOKEN';
export const GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN = 'GetLeagueSeasonsOutputPort_TOKEN';
export const JOIN_LEAGUE_OUTPUT_PORT_TOKEN = 'JoinLeagueOutputPort_TOKEN';
@@ -157,6 +184,8 @@ export const LeagueProviders: Provider[] = [
CreateLeaguePresenter,
GetLeagueAdminPermissionsPresenter,
GetLeagueMembershipsPresenter,
GetLeagueRosterMembersPresenter,
GetLeagueRosterJoinRequestsPresenter,
GetLeagueOwnerSummaryPresenter,
GetLeagueProtestsPresenter,
GetLeagueSeasonsPresenter,
@@ -177,6 +206,11 @@ export const LeagueProviders: Provider[] = [
TransferLeagueOwnershipPresenter,
UpdateLeagueMemberRolePresenter,
WithdrawFromLeagueWalletPresenter,
CreateLeagueSeasonScheduleRacePresenter,
UpdateLeagueSeasonScheduleRacePresenter,
DeleteLeagueSeasonScheduleRacePresenter,
PublishLeagueSeasonSchedulePresenter,
UnpublishLeagueSeasonSchedulePresenter,
// Output ports
{
provide: GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
@@ -218,6 +252,14 @@ export const LeagueProviders: Provider[] = [
provide: GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueMembershipsPresenter,
},
{
provide: GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueRosterMembersPresenter,
},
{
provide: GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueRosterJoinRequestsPresenter,
},
{
provide: GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueOwnerSummaryPresenter,
@@ -274,6 +316,29 @@ export const LeagueProviders: Provider[] = [
provide: WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
useExisting: WithdrawFromLeagueWalletPresenter,
},
// Schedule mutation output ports
{
provide: LeagueTokens.CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: CreateLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: UpdateLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: DeleteLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
useExisting: PublishLeagueSeasonSchedulePresenter,
},
{
provide: LeagueTokens.UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
useExisting: UnpublishLeagueSeasonSchedulePresenter,
},
// Use cases
{
provide: GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
@@ -346,23 +411,43 @@ export const LeagueProviders: Provider[] = [
},
{
provide: GET_LEAGUE_JOIN_REQUESTS_USE_CASE,
useClass: GetLeagueJoinRequestsUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: LeagueJoinRequestsPresenter,
) => new GetLeagueJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LeagueJoinRequestsPresenter],
},
{
provide: APPROVE_LEAGUE_JOIN_REQUEST_USE_CASE,
useClass: ApproveLeagueJoinRequestUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
leagueRepo: ILeagueRepository,
) => new ApproveLeagueJoinRequestUseCase(membershipRepo, leagueRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
},
{
provide: REJECT_LEAGUE_JOIN_REQUEST_USE_CASE,
useClass: RejectLeagueJoinRequestUseCase,
useFactory: (membershipRepo: ILeagueMembershipRepository) =>
new RejectLeagueJoinRequestUseCase(membershipRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: REMOVE_LEAGUE_MEMBER_USE_CASE,
useClass: RemoveLeagueMemberUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
output: RemoveLeagueMemberPresenter,
) => new RemoveLeagueMemberUseCase(membershipRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN],
},
{
provide: UPDATE_LEAGUE_MEMBER_ROLE_USE_CASE,
useClass: UpdateLeagueMemberRoleUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
output: UpdateLeagueMemberRolePresenter,
) => new UpdateLeagueMemberRoleUseCase(membershipRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN],
},
{
provide: GET_LEAGUE_OWNER_SUMMARY_USE_CASE,
@@ -391,15 +476,75 @@ export const LeagueProviders: Provider[] = [
},
{
provide: GET_LEAGUE_MEMBERSHIPS_USE_CASE,
useClass: GetLeagueMembershipsUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueMembershipsPresenter,
) => new GetLeagueMembershipsUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, GetLeagueMembershipsPresenter],
},
{
provide: GET_LEAGUE_ROSTER_MEMBERS_USE_CASE,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueRosterMembersPresenter,
) => new GetLeagueRosterMembersUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
],
},
{
provide: GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueRosterJoinRequestsPresenter,
) => new GetLeagueRosterJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
],
},
{
provide: GET_LEAGUE_SCHEDULE_USE_CASE,
useClass: GetLeagueScheduleUseCase,
useFactory: (
leagueRepo: ILeagueRepository,
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: LeagueSchedulePresenter,
) => new GetLeagueScheduleUseCase(leagueRepo, seasonRepo, raceRepo, logger, output),
inject: [
LEAGUE_REPOSITORY_TOKEN,
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
{
provide: GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE,
useClass: GetLeagueAdminPermissionsUseCase,
useFactory: (
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
output: GetLeagueAdminPermissionsPresenter,
) => new GetLeagueAdminPermissionsUseCase(leagueRepo, leagueMembershipRepo, logger, output),
inject: [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN,
],
},
{
provide: GET_LEAGUE_WALLET_USE_CASE,
@@ -468,7 +613,12 @@ export const LeagueProviders: Provider[] = [
},
{
provide: JOIN_LEAGUE_USE_CASE,
useClass: JoinLeagueUseCase,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
logger: Logger,
output: JoinLeaguePresenter,
) => new JoinLeagueUseCase(membershipRepo, logger, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, JoinLeaguePresenter],
},
{
provide: TRANSFER_LEAGUE_OWNERSHIP_USE_CASE,
@@ -477,5 +627,81 @@ export const LeagueProviders: Provider[] = [
{
provide: GET_LEAGUE_SCORING_CONFIG_USE_CASE,
useClass: GetLeagueScoringConfigUseCase,
}
},
// Schedule mutation use cases
{
provide: LeagueTokens.CREATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
useFactory: (
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: CreateLeagueSeasonScheduleRacePresenter,
) =>
new CreateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output, {
generateRaceId: () => `race-${randomUUID()}`,
}),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
provide: LeagueTokens.UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
useFactory: (
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: UpdateLeagueSeasonScheduleRacePresenter,
) => new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
provide: LeagueTokens.DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
useFactory: (
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: DeleteLeagueSeasonScheduleRacePresenter,
) => new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
provide: LeagueTokens.PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
useFactory: (
seasonRepo: ISeasonRepository,
logger: Logger,
output: PublishLeagueSeasonSchedulePresenter,
) => new PublishLeagueSeasonScheduleUseCase(seasonRepo, logger, output),
inject: [
SEASON_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
{
provide: LeagueTokens.UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
useFactory: (
seasonRepo: ISeasonRepository,
logger: Logger,
output: UnpublishLeagueSeasonSchedulePresenter,
) => new UnpublishLeagueSeasonScheduleUseCase(seasonRepo, logger, output),
inject: [
SEASON_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
];

View File

@@ -0,0 +1,154 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
describe('League roster admin read (HTTP, league-scoped)', () => {
const originalEnv = { ...process.env };
let app: any;
beforeAll(async () => {
vi.resetModules();
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
process.env.GRIDPILOT_API_BOOTSTRAP = 'true';
delete process.env.DATABASE_URL;
const { AppModule } = await import('../../app.module');
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
// Ensure AsyncLocalStorage request context is present for getActorFromRequestContext()
app.use(requestContextMiddleware);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const reflector = new Reflector();
const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN);
const authorizationService = {
getRolesForUser: () => [],
};
const policyService = {
getSnapshot: async () => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
}),
};
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
}, 20_000);
afterAll(async () => {
await app?.close();
process.env = originalEnv;
vi.restoreAllMocks();
});
it('rejects unauthenticated actor (401)', async () => {
await request(app.getHttpServer()).get('/leagues/league-5/admin/roster/members').expect(401);
await request(app.getHttpServer()).get('/leagues/league-5/admin/roster/join-requests').expect(401);
});
it('rejects authenticated non-admin actor (403)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/signup')
.send({ email: 'roster-read-user@gridpilot.local', password: 'pw1', displayName: 'Roster Read User' })
.expect(201);
await agent.get('/leagues/league-5/admin/roster/members').expect(403);
await agent.get('/leagues/league-5/admin/roster/join-requests').expect(403);
});
it('returns roster members with stable fields (happy path)', async () => {
const agent = request.agent(app.getHttpServer());
await agent.post('/auth/login').send({ email: 'admin@gridpilot.local', password: 'admin123' }).expect(201);
const res = await agent.get('/leagues/league-5/admin/roster/members').expect(200);
expect(res.body).toEqual(expect.any(Array));
expect(res.body.length).toBeGreaterThan(0);
const first = res.body[0] as any;
expect(first).toMatchObject({
driverId: expect.any(String),
role: expect.any(String),
joinedAt: expect.any(String),
driver: expect.any(Object),
});
expect(first.driver).toMatchObject({
id: expect.any(String),
name: expect.any(String),
country: expect.any(String),
});
expect(['owner', 'admin', 'steward', 'member']).toContain(first.role);
});
it('returns join requests with stable fields (happy path)', async () => {
const adminAgent = request.agent(app.getHttpServer());
await adminAgent.post('/auth/login').send({ email: 'admin@gridpilot.local', password: 'admin123' }).expect(201);
const res = await adminAgent.get('/leagues/league-5/admin/roster/join-requests').expect(200);
expect(res.body).toEqual(expect.any(Array));
// Seed data may or may not include join requests for a given league.
// Validate shape on first item if present.
if ((res.body as any[]).length > 0) {
const first = (res.body as any[])[0];
expect(first).toMatchObject({
id: expect.any(String),
leagueId: expect.any(String),
driverId: expect.any(String),
requestedAt: expect.any(String),
driver: {
id: expect.any(String),
name: expect.any(String),
},
});
if (first.message !== undefined) {
expect(first.message).toEqual(expect.any(String));
}
}
});
});

View File

@@ -0,0 +1,269 @@
import 'reflect-metadata';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import request from 'supertest';
import { ValidationPipe } from '@nestjs/common';
import { LeagueModule } from './LeagueModule';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import {
DRIVER_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
} from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
import { League } from '@core/racing/domain/entities/League';
import { Driver } from '@core/racing/domain/entities/Driver';
import { JoinRequest } from '@core/racing/domain/entities/JoinRequest';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
describe('League roster join request mutations (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
async function seedLeagueWithJoinRequest(params: {
leagueId: string;
adminId: string;
requesterId: string;
joinRequestId: string;
maxDrivers?: number;
extraActiveMemberId?: string;
}): Promise<void> {
const leagueRepo = app.get(LEAGUE_REPOSITORY_TOKEN);
const driverRepo = app.get(DRIVER_REPOSITORY_TOKEN);
const membershipRepo = app.get(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN);
await leagueRepo.create(
League.create({
id: params.leagueId,
name: 'Test League',
description: 'Test league',
ownerId: params.adminId,
settings: { visibility: 'unranked', ...(params.maxDrivers !== undefined ? { maxDrivers: params.maxDrivers } : {}) },
}),
);
await driverRepo.create(
Driver.create({
id: params.adminId,
iracingId: '1001',
name: 'Admin Driver',
country: 'DE',
}),
);
await driverRepo.create(
Driver.create({
id: params.requesterId,
iracingId: '1002',
name: 'Requester Driver',
country: 'DE',
}),
);
await membershipRepo.saveMembership(
LeagueMembership.create({
leagueId: params.leagueId,
driverId: params.adminId,
role: 'admin',
status: 'active',
}),
);
if (params.extraActiveMemberId) {
await driverRepo.create(
Driver.create({
id: params.extraActiveMemberId,
iracingId: '1003',
name: 'Extra Member',
country: 'DE',
}),
);
await membershipRepo.saveMembership(
LeagueMembership.create({
leagueId: params.leagueId,
driverId: params.extraActiveMemberId,
role: 'member',
status: 'active',
}),
);
}
await membershipRepo.saveJoinRequest(
JoinRequest.create({
id: params.joinRequestId,
leagueId: params.leagueId,
driverId: params.requesterId,
requestedAt: new Date('2025-01-01T12:00:00Z'),
message: 'please',
}),
);
}
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [LeagueModule],
}).compile();
app = module.createNestApplication();
// Required for getActorFromRequestContext() used by requireLeagueAdminOrOwner().
app.use(requestContextMiddleware as any);
// Test-only auth injection: emulate an authenticated session by setting request.user.
app.use((req: any, _res: any, next: any) => {
const userId = req.headers['x-test-user-id'];
if (typeof userId === 'string' && userId.length > 0) {
req.user = { userId };
}
next();
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('returns 401 when unauthenticated', async () => {
await seedLeagueWithJoinRequest({
leagueId: 'league-1',
adminId: 'admin-1',
requesterId: 'driver-2',
joinRequestId: 'jr-1',
});
await request(app.getHttpServer())
.post('/leagues/league-1/admin/roster/join-requests/jr-1/approve')
.expect(401);
});
it('returns 403 when authenticated but not admin/owner', async () => {
await seedLeagueWithJoinRequest({
leagueId: 'league-1',
adminId: 'admin-1',
requesterId: 'driver-2',
joinRequestId: 'jr-1',
});
await request(app.getHttpServer())
.post('/leagues/league-1/admin/roster/join-requests/jr-1/approve')
.set('x-test-user-id', 'user-2')
.expect(403);
});
it('approve removes request and adds member; roster reads reflect changes', async () => {
await seedLeagueWithJoinRequest({
leagueId: 'league-1',
adminId: 'admin-1',
requesterId: 'driver-2',
joinRequestId: 'jr-1',
});
await request(app.getHttpServer())
.post('/leagues/league-1/admin/roster/join-requests/jr-1/approve')
.set('x-test-user-id', 'admin-1')
.expect(200);
const joinRequests = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/join-requests')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(joinRequests.body)).toBe(true);
expect(joinRequests.body.find((r: any) => r.id === 'jr-1')).toBeUndefined();
const members = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/members')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(members.body)).toBe(true);
expect(members.body.some((m: any) => m.driverId === 'driver-2')).toBe(true);
});
it('reject removes request only; roster reads reflect changes', async () => {
await seedLeagueWithJoinRequest({
leagueId: 'league-1',
adminId: 'admin-1',
requesterId: 'driver-2',
joinRequestId: 'jr-1',
});
await request(app.getHttpServer())
.post('/leagues/league-1/admin/roster/join-requests/jr-1/reject')
.set('x-test-user-id', 'admin-1')
.expect(200);
const joinRequests = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/join-requests')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(joinRequests.body)).toBe(true);
expect(joinRequests.body.find((r: any) => r.id === 'jr-1')).toBeUndefined();
const members = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/members')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(members.body)).toBe(true);
expect(members.body.some((m: any) => m.driverId === 'driver-2')).toBe(false);
});
it('approve returns error when league is full and keeps request pending', async () => {
await seedLeagueWithJoinRequest({
leagueId: 'league-1',
adminId: 'admin-1',
requesterId: 'driver-2',
joinRequestId: 'jr-1',
maxDrivers: 2,
extraActiveMemberId: 'driver-3',
});
await request(app.getHttpServer())
.post('/leagues/league-1/admin/roster/join-requests/jr-1/approve')
.set('x-test-user-id', 'admin-1')
.expect(409);
const joinRequests = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/join-requests')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(joinRequests.body)).toBe(true);
expect(joinRequests.body.find((r: any) => r.id === 'jr-1')).toBeDefined();
});
});

View File

@@ -0,0 +1,197 @@
import 'reflect-metadata';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import request from 'supertest';
import { ValidationPipe } from '@nestjs/common';
import { LeagueModule } from './LeagueModule';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import {
DRIVER_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
} from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
import { League } from '@core/racing/domain/entities/League';
import { Driver } from '@core/racing/domain/entities/Driver';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
describe('League roster member mutations (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
async function seedLeagueWithMembers(params: {
leagueId: string;
adminId: string;
memberId: string;
}): Promise<void> {
const leagueRepo = app.get(LEAGUE_REPOSITORY_TOKEN);
const driverRepo = app.get(DRIVER_REPOSITORY_TOKEN);
const membershipRepo = app.get(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN);
await leagueRepo.create(
League.create({
id: params.leagueId,
name: 'Test League',
description: 'Test league',
ownerId: params.adminId,
settings: { visibility: 'unranked' },
}),
);
await driverRepo.create(
Driver.create({
id: params.adminId,
iracingId: '2001',
name: 'Admin Driver',
country: 'DE',
}),
);
await driverRepo.create(
Driver.create({
id: params.memberId,
iracingId: '2002',
name: 'Member Driver',
country: 'DE',
}),
);
await membershipRepo.saveMembership(
LeagueMembership.create({
leagueId: params.leagueId,
driverId: params.adminId,
role: 'admin',
status: 'active',
}),
);
await membershipRepo.saveMembership(
LeagueMembership.create({
leagueId: params.leagueId,
driverId: params.memberId,
role: 'member',
status: 'active',
}),
);
}
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [LeagueModule],
}).compile();
app = module.createNestApplication();
// Required for getActorFromRequestContext() used by requireLeagueAdminOrOwner().
app.use(requestContextMiddleware as any);
// Test-only auth injection: emulate an authenticated session by setting request.user.
app.use((req: any, _res: any, next: any) => {
const userId = req.headers['x-test-user-id'];
if (typeof userId === 'string' && userId.length > 0) {
req.user = { userId };
}
next();
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('returns 401 when unauthenticated (role change + removal)', async () => {
await seedLeagueWithMembers({ leagueId: 'league-1', adminId: 'admin-1', memberId: 'driver-2' });
await request(app.getHttpServer())
.patch('/leagues/league-1/admin/roster/members/driver-2/role')
.send({ newRole: 'steward' })
.expect(401);
await request(app.getHttpServer()).patch('/leagues/league-1/admin/roster/members/driver-2/remove').expect(401);
});
it('returns 403 when authenticated but not admin/owner (role change + removal)', async () => {
await seedLeagueWithMembers({ leagueId: 'league-1', adminId: 'admin-1', memberId: 'driver-2' });
await request(app.getHttpServer())
.patch('/leagues/league-1/admin/roster/members/driver-2/role')
.set('x-test-user-id', 'user-2')
.send({ newRole: 'steward' })
.expect(403);
await request(app.getHttpServer())
.patch('/leagues/league-1/admin/roster/members/driver-2/remove')
.set('x-test-user-id', 'user-2')
.expect(403);
});
it('role change is reflected in roster members read', async () => {
await seedLeagueWithMembers({ leagueId: 'league-1', adminId: 'admin-1', memberId: 'driver-2' });
await request(app.getHttpServer())
.patch('/leagues/league-1/admin/roster/members/driver-2/role')
.set('x-test-user-id', 'admin-1')
.send({ newRole: 'steward' })
.expect(200);
const members = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/members')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(members.body)).toBe(true);
const updated = (members.body as any[]).find(m => m.driverId === 'driver-2');
expect(updated).toBeDefined();
expect(updated.role).toBe('steward');
});
it('member removal is reflected in roster members read', async () => {
await seedLeagueWithMembers({ leagueId: 'league-1', adminId: 'admin-1', memberId: 'driver-2' });
await request(app.getHttpServer())
.patch('/leagues/league-1/admin/roster/members/driver-2/remove')
.set('x-test-user-id', 'admin-1')
.expect(200);
const members = await request(app.getHttpServer())
.get('/leagues/league-1/admin/roster/members')
.set('x-test-user-id', 'admin-1')
.expect(200);
expect(Array.isArray(members.body)).toBe(true);
expect((members.body as any[]).some(m => m.driverId === 'driver-2')).toBe(false);
});
});

View File

@@ -0,0 +1,191 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
describe('League schedule admin CRUD (HTTP, season-scoped)', () => {
const originalEnv = { ...process.env };
let app: any;
beforeAll(async () => {
vi.resetModules();
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
process.env.GRIDPILOT_API_BOOTSTRAP = 'true';
delete process.env.DATABASE_URL;
const { AppModule } = await import('../../app.module');
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
// Ensure AsyncLocalStorage request context is present for getActorFromRequestContext()
app.use(requestContextMiddleware);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const reflector = new Reflector();
const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN);
const authorizationService = {
getRolesForUser: () => [],
};
const policyService = {
getSnapshot: async () => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
}),
};
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
}, 20_000);
afterAll(async () => {
await app?.close();
process.env = originalEnv;
vi.restoreAllMocks();
});
it('rejects unauthenticated actor (401)', async () => {
await request(app.getHttpServer())
.post('/leagues/league-5/seasons/season-1/schedule/races')
.send({
track: 'Test Track',
car: 'Test Car',
scheduledAtIso: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
.expect(401);
});
it('rejects authenticated non-admin actor (403)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/signup')
.send({ email: 'user1@gridpilot.local', password: 'pw1', displayName: 'User 1' })
.expect(201);
await agent
.post('/leagues/league-5/seasons/season-1/schedule/races')
.send({
track: 'Test Track',
car: 'Test Car',
scheduledAtIso: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
.expect(403);
});
it('rejects payload identity spoofing (400)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/login')
.send({ email: 'admin@gridpilot.local', password: 'admin123' })
.expect(201);
await agent
.post('/leagues/league-5/seasons/season-1/schedule/races')
.send({
track: 'Test Track',
car: 'Test Car',
scheduledAtIso: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
performerDriverId: 'driver-1',
})
.expect(400);
});
it('create/update/delete changes schedule read for the same season (happy path)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/login')
.send({ email: 'admin@gridpilot.local', password: 'admin123' })
.expect(201);
const initialScheduleRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
expect(initialScheduleRes.body).toMatchObject({ seasonId: 'season-1', races: expect.any(Array) });
const scheduledAtIso = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
const createRes = await agent
.post('/leagues/league-5/seasons/season-1/schedule/races')
.send({
track: 'Test Track',
car: 'Test Car',
scheduledAtIso,
})
.expect(201);
expect(createRes.body).toMatchObject({ raceId: expect.any(String) });
const raceId: string = createRes.body.raceId;
const afterCreateRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
const createdRace = (afterCreateRes.body.races as any[]).find((r) => r.id === raceId);
expect(createdRace).toMatchObject({
id: raceId,
name: 'Test Track - Test Car',
date: scheduledAtIso,
});
const updatedAtIso = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
await agent
.patch(`/leagues/league-5/seasons/season-1/schedule/races/${raceId}`)
.send({
track: 'Updated Track',
car: 'Updated Car',
scheduledAtIso: updatedAtIso,
})
.expect(200)
.expect((res) => {
expect(res.body).toEqual({ success: true });
});
const afterUpdateRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
const updatedRace = (afterUpdateRes.body.races as any[]).find((r) => r.id === raceId);
expect(updatedRace).toMatchObject({
id: raceId,
name: 'Updated Track - Updated Car',
date: updatedAtIso,
});
await agent.delete(`/leagues/league-5/seasons/season-1/schedule/races/${raceId}`).expect(200).expect({
success: true,
});
const afterDeleteRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
const deletedRace = (afterDeleteRes.body.races as any[]).find((r) => r.id === raceId);
expect(deletedRace).toBeUndefined();
});
});

View File

@@ -0,0 +1,149 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
describe('League season schedule publish/unpublish (HTTP, season-scoped)', () => {
const originalEnv = { ...process.env };
let app: any;
beforeAll(async () => {
vi.resetModules();
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
process.env.GRIDPILOT_API_BOOTSTRAP = 'true';
delete process.env.DATABASE_URL;
const { AppModule } = await import('../../app.module');
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
// Ensure AsyncLocalStorage request context is present for getActorFromRequestContext()
app.use(requestContextMiddleware);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const reflector = new Reflector();
const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN);
const authorizationService = {
getRolesForUser: () => [],
};
const policyService = {
getSnapshot: async () => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
}),
};
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
}, 20_000);
afterAll(async () => {
await app?.close();
process.env = originalEnv;
vi.restoreAllMocks();
});
it('rejects unauthenticated actor (401)', async () => {
await request(app.getHttpServer())
.post('/leagues/league-5/seasons/season-1/schedule/publish')
.send({})
.expect(401);
await request(app.getHttpServer())
.post('/leagues/league-5/seasons/season-1/schedule/unpublish')
.send({})
.expect(401);
});
it('rejects authenticated non-admin actor (403)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/signup')
.send({ email: 'user2@gridpilot.local', password: 'pw2', displayName: 'User 2' })
.expect(201);
await agent.post('/leagues/league-5/seasons/season-1/schedule/publish').send({}).expect(403);
await agent.post('/leagues/league-5/seasons/season-1/schedule/unpublish').send({}).expect(403);
});
it('publish/unpublish toggles state and is reflected via schedule read (happy path)', async () => {
const agent = request.agent(app.getHttpServer());
await agent
.post('/auth/login')
.send({ email: 'admin@gridpilot.local', password: 'admin123' })
.expect(201);
const initialScheduleRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
expect(initialScheduleRes.body).toMatchObject({
seasonId: 'season-1',
published: false,
races: expect.any(Array),
});
await agent
.post('/leagues/league-5/seasons/season-1/schedule/publish')
.send({})
.expect(200)
.expect((res) => {
expect(res.body).toEqual({ success: true, published: true });
});
const afterPublishRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
expect(afterPublishRes.body).toMatchObject({
seasonId: 'season-1',
published: true,
races: expect.any(Array),
});
await agent
.post('/leagues/league-5/seasons/season-1/schedule/unpublish')
.send({})
.expect(200)
.expect((res) => {
expect(res.body).toEqual({ success: true, published: false });
});
const afterUnpublishRes = await agent.get('/leagues/league-5/schedule?seasonId=season-1').expect(200);
expect(afterUnpublishRes.body).toMatchObject({
seasonId: 'season-1',
published: false,
races: expect.any(Array),
});
});
});

View File

@@ -1,7 +1,19 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { LeagueService } from './LeagueService';
async function withUserId<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const req = { user: { userId } };
const res = {};
return await new Promise<T>((resolve, reject) => {
requestContextMiddleware(req as any, res as any, () => {
fn().then(resolve, reject);
});
});
}
describe('LeagueService', () => {
it('covers LeagueService happy paths and error branches', async () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
@@ -35,6 +47,16 @@ describe('LeagueService', () => {
const withdrawFromLeagueWalletUseCase = { execute: vi.fn(ok) };
const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) };
const getLeagueRosterMembersUseCase = { execute: vi.fn(ok) };
const getLeagueRosterJoinRequestsUseCase = { execute: vi.fn(ok) };
// Schedule mutation use cases (must be called by LeagueService, not repositories)
const createLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const updateLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const deleteLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const publishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
const unpublishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) };
const allLeaguesWithCapacityAndScoringPresenter = {
present: vi.fn(),
@@ -47,11 +69,26 @@ describe('LeagueService', () => {
const approveLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const createLeaguePresenter = { getViewModel: vi.fn(() => ({ id: 'l1' })) };
const getLeagueAdminPermissionsPresenter = { getResponseModel: vi.fn(() => ({ canManage: true })) };
const getLeagueMembershipsPresenter = { getViewModel: vi.fn(() => ({ memberships: { memberships: [] } })) };
const getLeagueMembershipsPresenter = {
reset: vi.fn(),
getViewModel: vi.fn(() => ({ memberships: { memberships: [] } })),
};
const getLeagueRosterMembersPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ([])),
};
const getLeagueRosterJoinRequestsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ([])),
};
const getLeagueOwnerSummaryPresenter = { getViewModel: vi.fn(() => ({ ownerId: 'o1' })) };
const getLeagueSeasonsPresenter = { getResponseModel: vi.fn(() => ([])) };
const joinLeaguePresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const leagueSchedulePresenter = { getViewModel: vi.fn(() => ({ schedule: [] })) };
const leagueSchedulePresenter = { getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) };
const leagueStatsPresenter = { getResponseModel: vi.fn(() => ({ stats: {} })) };
const rejectLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const removeLeagueMemberPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
@@ -65,7 +102,13 @@ describe('LeagueService', () => {
const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const leagueRacesPresenter = { getViewModel: vi.fn(() => ([])) };
const service = new LeagueService(
const createLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
const updateLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const deleteLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const publishLeagueSeasonSchedulePresenter = { getResponseModel: vi.fn(() => ({ success: true, published: true })) };
const unpublishLeagueSeasonSchedulePresenter = { getResponseModel: vi.fn(() => ({ success: true, published: false })) };
const service = new (LeagueService as any)(
getAllLeaguesWithCapacityUseCase as any,
getAllLeaguesWithCapacityAndScoringUseCase as any,
getLeagueStandingsUseCase as any,
@@ -91,6 +134,11 @@ describe('LeagueService', () => {
getLeagueWalletUseCase as any,
withdrawFromLeagueWalletUseCase as any,
getSeasonSponsorshipsUseCase as any,
createLeagueSeasonScheduleRaceUseCase as any,
updateLeagueSeasonScheduleRaceUseCase as any,
deleteLeagueSeasonScheduleRaceUseCase as any,
publishLeagueSeasonScheduleUseCase as any,
unpublishLeagueSeasonScheduleUseCase as any,
logger as any,
allLeaguesWithCapacityPresenter as any,
allLeaguesWithCapacityAndScoringPresenter as any,
@@ -118,17 +166,77 @@ describe('LeagueService', () => {
withdrawFromLeagueWalletPresenter as any,
leagueJoinRequestsPresenter as any,
leagueRacesPresenter as any,
createLeagueSeasonScheduleRacePresenter as any,
updateLeagueSeasonScheduleRacePresenter as any,
deleteLeagueSeasonScheduleRacePresenter as any,
publishLeagueSeasonSchedulePresenter as any,
unpublishLeagueSeasonSchedulePresenter as any,
// Roster admin read delegation (added for strict TDD)
getLeagueRosterMembersUseCase as any,
getLeagueRosterJoinRequestsUseCase as any,
getLeagueRosterMembersPresenter as any,
getLeagueRosterJoinRequestsPresenter as any,
);
await expect(service.getTotalLeagues()).resolves.toEqual({ total: 1 });
await expect(service.getLeagueJoinRequests('l1')).resolves.toEqual([]);
await withUserId('user-1', async () => {
await expect(service.getLeagueJoinRequests('l1')).resolves.toEqual([]);
});
await expect(service.approveLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({ success: true });
await expect(service.rejectLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({ success: true });
await withUserId('user-1', async () => {
await expect(service.approveLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({
success: true,
});
});
await expect(service.getLeagueAdminPermissions({ leagueId: 'l1' } as any)).resolves.toEqual({ canManage: true });
await expect(service.removeLeagueMember({ leagueId: 'l1', targetDriverId: 'd1' } as any)).resolves.toEqual({ success: true });
await expect(service.updateLeagueMemberRole({ leagueId: 'l1', targetDriverId: 'd1', newRole: 'member' } as any)).resolves.toEqual({ success: true });
await withUserId('user-1', async () => {
await expect(service.rejectLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({
success: true,
});
});
expect(rejectLeagueJoinRequestUseCase.execute).toHaveBeenCalledWith(
{ leagueId: 'l1', joinRequestId: 'r1' },
rejectLeagueJoinRequestPresenter,
);
await withUserId('user-1', async () => {
await expect(service.getLeagueAdminPermissions({ leagueId: 'l1' } as any)).resolves.toEqual({ canManage: true });
});
await withUserId('user-1', async () => {
await expect(service.removeLeagueMember({ leagueId: 'l1', targetDriverId: 'd1' } as any)).resolves.toEqual({
success: true,
});
});
expect(getLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
performerDriverId: 'user-1',
});
expect(removeLeagueMemberUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
targetDriverId: 'd1',
});
await withUserId('user-1', async () => {
await expect(service.updateLeagueMemberRole('l1', 'd1', { newRole: 'member' } as any)).resolves.toEqual({
success: true,
});
});
expect(getLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
performerDriverId: 'user-1',
});
expect(updateLeagueMemberRoleUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
targetDriverId: 'd1',
newRole: 'member',
});
await expect(service.getLeagueOwnerSummary({ leagueId: 'l1' } as any)).resolves.toEqual({ ownerId: 'o1' });
await expect(service.getLeagueProtests({ leagueId: 'l1' } as any)).resolves.toEqual({ protests: [] });
@@ -138,21 +246,165 @@ describe('LeagueService', () => {
await expect(service.getLeagueScoringConfig('l1')).resolves.toEqual({ config: {} });
await expect(service.getLeagueMemberships('l1')).resolves.toEqual({ memberships: [] });
// Roster admin read endpoints must delegate to core use cases (tests fail until refactor)
await withUserId('user-1', async () => {
await service.getLeagueRosterMembers('l1');
});
expect(getLeagueRosterMembersUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(getLeagueRosterMembersPresenter.reset).toHaveBeenCalled();
await withUserId('user-1', async () => {
await service.getLeagueRosterJoinRequests('l1');
});
expect(getLeagueRosterJoinRequestsUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(getLeagueRosterJoinRequestsPresenter.reset).toHaveBeenCalled();
// Roster admin read endpoints must be admin-gated (auth boundary) and must not execute core use cases on 403.
getLeagueAdminPermissionsUseCase.execute.mockResolvedValueOnce(
Result.err({ code: 'FORBIDDEN', details: { message: 'nope' } }) as any,
);
getLeagueRosterMembersUseCase.execute.mockClear();
await withUserId('user-2', async () => {
await expect(service.getLeagueRosterMembers('l1')).rejects.toThrow('Forbidden');
});
expect(getLeagueRosterMembersUseCase.execute).not.toHaveBeenCalled();
await expect(service.getLeagueStandings('l1')).resolves.toEqual({ standings: [] });
await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ schedule: [] });
await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ seasonId: 'season-1', published: false, races: [] });
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
getLeagueScheduleUseCase.execute.mockClear();
await expect(service.getLeagueSchedule('l1', { seasonId: 'season-x' } as any)).resolves.toEqual({
seasonId: 'season-1',
published: false,
races: [],
});
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-x' });
await expect(service.getLeagueStats('l1')).resolves.toEqual({ stats: {} });
await expect(service.createLeague({ name: 'n', description: 'd', ownerId: 'o' } as any)).resolves.toEqual({ id: 'l1' });
await expect(service.listLeagueScoringPresets()).resolves.toEqual({ presets: [] });
await expect(service.joinLeague('l1', 'd1')).resolves.toEqual({ success: true });
await expect(service.transferLeagueOwnership('l1', 'o1', 'o2')).resolves.toEqual({ success: true });
await withUserId('user-1', async () => {
await expect(service.joinLeague('l1')).resolves.toEqual({ success: true });
});
await withUserId('user-1', async () => {
await expect(service.transferLeagueOwnership('l1', { newOwnerId: 'o2' } as any)).resolves.toEqual({ success: true });
});
// Transfer ownership must be admin-gated and actor-derived (no payload owner/admin IDs)
getLeagueAdminPermissionsUseCase.execute.mockClear();
transferLeagueOwnershipUseCase.execute.mockClear();
await withUserId('user-1', async () => {
await expect(
service.transferLeagueOwnership('l1', { newOwnerId: 'o2', currentOwnerId: 'spoof' } as any),
).resolves.toEqual({ success: true });
});
expect(getLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
performerDriverId: 'user-1',
});
expect(transferLeagueOwnershipUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
currentOwnerId: 'user-1',
newOwnerId: 'o2',
});
// Unauthorized (non-admin/owner) actors are rejected
getLeagueAdminPermissionsUseCase.execute.mockResolvedValueOnce(
Result.err({ code: 'FORBIDDEN', details: { message: 'nope' } }) as any,
);
transferLeagueOwnershipUseCase.execute.mockClear();
await withUserId('user-2', async () => {
await expect(service.transferLeagueOwnership('l1', { newOwnerId: 'o2' } as any)).rejects.toThrow('Forbidden');
});
expect(transferLeagueOwnershipUseCase.execute).not.toHaveBeenCalled();
// Actor must be derived from session (request context), not payload arguments.
joinLeagueUseCase.execute.mockClear();
await withUserId('user-1', async () => {
await expect(service.joinLeague('l1')).resolves.toEqual({ success: true });
});
expect(joinLeagueUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', driverId: 'user-1' });
await expect(service.getSeasonSponsorships('s1')).resolves.toEqual({ sponsorships: [] });
await expect(service.getRaces('l1')).resolves.toEqual({ races: [] });
await expect(service.getLeagueWallet('l1')).resolves.toEqual({ balance: 0 });
await expect(service.withdrawFromLeagueWallet('l1', { amount: 1, currency: 'USD', destinationAccount: 'x' } as any)).resolves.toEqual({
success: true,
await withUserId('user-1', async () => {
await expect(
service.publishLeagueSeasonSchedule('l1', 'season-1', {} as any),
).resolves.toEqual({ success: true, published: true });
await expect(
service.unpublishLeagueSeasonSchedule('l1', 'season-1', {} as any),
).resolves.toEqual({ success: true, published: false });
await expect(
service.createLeagueSeasonScheduleRace('l1', 'season-1', {
track: 'Spa',
car: 'GT3',
scheduledAtIso: new Date('2025-01-10T20:00:00Z').toISOString(),
} as any),
).resolves.toEqual({ raceId: 'race-1' });
await expect(
service.updateLeagueSeasonScheduleRace('l1', 'season-1', 'race-1', {
track: 'Monza',
car: 'LMP2',
} as any),
).resolves.toEqual({ success: true });
await expect(
service.deleteLeagueSeasonScheduleRace('l1', 'season-1', 'race-1'),
).resolves.toEqual({ success: true });
});
expect(publishLeagueSeasonScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-1' });
expect(unpublishLeagueSeasonScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-1' });
expect(createLeagueSeasonScheduleRaceUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
seasonId: 'season-1',
track: 'Spa',
car: 'GT3',
scheduledAt: expect.any(Date),
});
expect(updateLeagueSeasonScheduleRaceUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
seasonId: 'season-1',
raceId: 'race-1',
track: 'Monza',
car: 'LMP2',
});
expect(deleteLeagueSeasonScheduleRaceUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
seasonId: 'season-1',
raceId: 'race-1',
});
await withUserId('user-1', async () => {
await expect(
service.withdrawFromLeagueWallet('l1', { amount: 1, currency: 'USD', destinationAccount: 'x' } as any),
).resolves.toEqual({
success: true,
});
});
expect(withdrawFromLeagueWalletUseCase.execute).toHaveBeenCalledWith({
leagueId: 'l1',
requestedById: 'user-1',
amount: 1,
currency: 'USD',
reason: 'x',
});
await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] });
@@ -186,16 +438,20 @@ describe('LeagueService', () => {
// getLeagueAdmin error branch: fullConfigResult is Err
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }));
await expect(service.getLeagueAdmin('l1')).rejects.toThrow('REPOSITORY_ERROR');
await withUserId('user-1', async () => {
await expect(service.getLeagueAdmin('l1')).rejects.toThrow('REPOSITORY_ERROR');
});
// getLeagueAdmin happy path
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(Result.ok(undefined));
await expect(service.getLeagueAdmin('l1')).resolves.toEqual({
joinRequests: [],
ownerSummary: { ownerId: 'o1' },
config: { form: { form: {} } },
protests: { protests: [] },
seasons: [],
await withUserId('user-1', async () => {
await expect(service.getLeagueAdmin('l1')).resolves.toEqual({
joinRequests: [],
ownerSummary: { ownerId: 'o1' },
config: { form: { form: {} } },
protests: { protests: [] },
seasons: [],
});
});
// keep lint happy (ensures err() used)

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
import { ApproveLeagueJoinRequestDTO } from './dtos/ApproveLeagueJoinRequestDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
@@ -9,6 +9,13 @@ import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
import { GetLeagueScheduleQueryDTO } from './dtos/GetLeagueScheduleQueryDTO';
import {
CreateLeagueScheduleRaceInputDTO,
CreateLeagueScheduleRaceOutputDTO,
LeagueScheduleRaceMutationSuccessDTO,
UpdateLeagueScheduleRaceInputDTO,
} from './dtos/LeagueScheduleRaceAdminDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
@@ -16,8 +23,14 @@ import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueJoinRequestWithDriverDTO } from './dtos/LeagueJoinRequestWithDriverDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueRosterJoinRequestDTO } from './dtos/LeagueRosterJoinRequestDTO';
import { LeagueRosterMemberDTO } from './dtos/LeagueRosterMemberDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import {
LeagueSeasonSchedulePublishInputDTO,
LeagueSeasonSchedulePublishOutputDTO,
} from './dtos/LeagueSeasonSchedulePublishDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
@@ -26,11 +39,15 @@ import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
import { TransferLeagueOwnershipOutputDTO } from './dtos/TransferLeagueOwnershipOutputDTO';
import { TransferLeagueOwnershipInputDTO } from './dtos/TransferLeagueOwnershipInputDTO';
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
import { getActorFromRequestContext } from '../auth/getActorFromRequestContext';
import { requireLeagueAdminOrOwner } from './LeagueAuthorization';
// Core imports for view models
import type { AllLeaguesWithCapacityDTO as AllLeaguesWithCapacityViewModel } from './dtos/AllLeaguesWithCapacityDTO';
import type { AllLeaguesWithCapacityAndScoringDTO as AllLeaguesWithCapacityAndScoringViewModel } from './dtos/AllLeaguesWithCapacityAndScoringDTO';
@@ -52,9 +69,12 @@ import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-c
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueRosterMembersUseCase } from '@core/racing/application/use-cases/GetLeagueRosterMembersUseCase';
import { GetLeagueRosterJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueRosterJoinRequestsUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import type { GetLeagueScheduleInput } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueScoringConfigUseCase } from '@core/racing/application/use-cases/GetLeagueScoringConfigUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
@@ -70,6 +90,12 @@ import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cas
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
import { CreateLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase';
import { DeleteLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase';
import { PublishLeagueSeasonScheduleUseCase } from '@core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase';
import { UnpublishLeagueSeasonScheduleUseCase } from '@core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase';
import { UpdateLeagueSeasonScheduleRaceUseCase } from '@core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase';
// API Presenters
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { AllLeaguesWithCapacityAndScoringPresenter } from './presenters/AllLeaguesWithCapacityAndScoringPresenter';
@@ -77,6 +103,10 @@ import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoi
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import {
GetLeagueRosterJoinRequestsPresenter,
GetLeagueRosterMembersPresenter,
} from './presenters/LeagueRosterAdminReadPresenters';
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
@@ -96,58 +126,74 @@ import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwn
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { GetLeagueWalletPresenter } from './presenters/GetLeagueWalletPresenter';
import { WithdrawFromLeagueWalletPresenter } from './presenters/WithdrawFromLeagueWalletPresenter';
import {
CreateLeagueSeasonScheduleRacePresenter,
DeleteLeagueSeasonScheduleRacePresenter,
PublishLeagueSeasonSchedulePresenter,
UnpublishLeagueSeasonSchedulePresenter,
UpdateLeagueSeasonScheduleRacePresenter,
} from './presenters/LeagueSeasonScheduleMutationPresenters';
// Tokens
import {
LOGGER_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
GET_LEAGUE_STANDINGS_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE,
GET_LEAGUE_STATS_USE_CASE,
GET_LEAGUE_FULL_CONFIG_USE_CASE,
GET_LEAGUE_SCORING_CONFIG_USE_CASE,
LIST_LEAGUE_SCORING_PRESETS_USE_CASE,
JOIN_LEAGUE_USE_CASE,
TRANSFER_LEAGUE_OWNERSHIP_USE_CASE,
CREATE_LEAGUE_WITH_SEASON_AND_SCORING_USE_CASE,
GET_TOTAL_LEAGUES_USE_CASE,
GET_LEAGUE_JOIN_REQUESTS_USE_CASE,
APPROVE_LEAGUE_JOIN_REQUEST_USE_CASE,
REJECT_LEAGUE_JOIN_REQUEST_USE_CASE,
REMOVE_LEAGUE_MEMBER_USE_CASE,
UPDATE_LEAGUE_MEMBER_ROLE_USE_CASE,
GET_LEAGUE_OWNER_SUMMARY_USE_CASE,
GET_LEAGUE_PROTESTS_USE_CASE,
GET_LEAGUE_SEASONS_USE_CASE,
GET_LEAGUE_MEMBERSHIPS_USE_CASE,
GET_LEAGUE_SCHEDULE_USE_CASE,
GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE,
GET_LEAGUE_WALLET_USE_CASE,
WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE,
GET_SEASON_SPONSORSHIPS_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN,
APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
APPROVE_LEAGUE_JOIN_REQUEST_USE_CASE,
CREATE_LEAGUE_OUTPUT_PORT_TOKEN,
CREATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
CREATE_LEAGUE_WITH_SEASON_AND_SCORING_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE,
GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_FULL_CONFIG_USE_CASE,
GET_LEAGUE_JOIN_REQUESTS_USE_CASE,
GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_MEMBERSHIPS_USE_CASE,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE,
GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ROSTER_MEMBERS_USE_CASE,
GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN,
JOIN_LEAGUE_OUTPUT_PORT_TOKEN,
GET_LEAGUE_OWNER_SUMMARY_USE_CASE,
GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_PROTESTS_USE_CASE,
GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SCHEDULE_USE_CASE,
GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SCORING_CONFIG_USE_CASE,
GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SEASONS_USE_CASE,
GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_STATS_USE_CASE,
GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_STANDINGS_USE_CASE,
GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
GET_LEAGUE_WALLET_USE_CASE,
GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
GET_SEASON_SPONSORSHIPS_USE_CASE,
GET_TOTAL_LEAGUES_USE_CASE,
JOIN_LEAGUE_OUTPUT_PORT_TOKEN,
JOIN_LEAGUE_USE_CASE,
LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN,
LIST_LEAGUE_SCORING_PRESETS_USE_CASE,
LOGGER_TOKEN,
PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
REJECT_LEAGUE_JOIN_REQUEST_USE_CASE,
REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN,
REMOVE_LEAGUE_MEMBER_USE_CASE,
TOTAL_LEAGUES_OUTPUT_PORT_TOKEN,
TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN,
TRANSFER_LEAGUE_OWNERSHIP_USE_CASE,
UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN,
GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
UPDATE_LEAGUE_MEMBER_ROLE_USE_CASE,
UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE,
} from './LeagueTokens';
@Injectable()
@@ -178,7 +224,21 @@ export class LeagueService {
@Inject(GET_LEAGUE_WALLET_USE_CASE) private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase,
@Inject(WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE) private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase,
@Inject(GET_SEASON_SPONSORSHIPS_USE_CASE) private readonly getSeasonSponsorshipsUseCase: GetSeasonSponsorshipsUseCase,
// Schedule mutations
@Inject(CREATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE)
private readonly createLeagueSeasonScheduleRaceUseCase: CreateLeagueSeasonScheduleRaceUseCase,
@Inject(UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE)
private readonly updateLeagueSeasonScheduleRaceUseCase: UpdateLeagueSeasonScheduleRaceUseCase,
@Inject(DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE)
private readonly deleteLeagueSeasonScheduleRaceUseCase: DeleteLeagueSeasonScheduleRaceUseCase,
@Inject(PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE)
private readonly publishLeagueSeasonScheduleUseCase: PublishLeagueSeasonScheduleUseCase,
@Inject(UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE)
private readonly unpublishLeagueSeasonScheduleUseCase: UnpublishLeagueSeasonScheduleUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
// Injected presenters
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityPresenter: AllLeaguesWithCapacityPresenter,
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityAndScoringPresenter: AllLeaguesWithCapacityAndScoringPresenter,
@@ -204,8 +264,30 @@ export class LeagueService {
@Inject(GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN) private readonly leagueScoringConfigPresenter: LeagueScoringConfigPresenter,
@Inject(GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter,
@Inject(WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter,
private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter,
private readonly leagueRacesPresenter: LeagueRacesPresenter,
@Inject(LeagueJoinRequestsPresenter) private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter,
@Inject(LeagueRacesPresenter) private readonly leagueRacesPresenter: LeagueRacesPresenter,
// Schedule mutation presenters
@Inject(CreateLeagueSeasonScheduleRacePresenter)
private readonly createLeagueSeasonScheduleRacePresenter: CreateLeagueSeasonScheduleRacePresenter,
@Inject(UpdateLeagueSeasonScheduleRacePresenter)
private readonly updateLeagueSeasonScheduleRacePresenter: UpdateLeagueSeasonScheduleRacePresenter,
@Inject(DeleteLeagueSeasonScheduleRacePresenter)
private readonly deleteLeagueSeasonScheduleRacePresenter: DeleteLeagueSeasonScheduleRacePresenter,
@Inject(PublishLeagueSeasonSchedulePresenter)
private readonly publishLeagueSeasonSchedulePresenter: PublishLeagueSeasonSchedulePresenter,
@Inject(UnpublishLeagueSeasonSchedulePresenter)
private readonly unpublishLeagueSeasonSchedulePresenter: UnpublishLeagueSeasonSchedulePresenter,
// Roster admin read delegation
@Inject(GET_LEAGUE_ROSTER_MEMBERS_USE_CASE)
private readonly getLeagueRosterMembersUseCase: GetLeagueRosterMembersUseCase,
@Inject(GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE)
private readonly getLeagueRosterJoinRequestsUseCase: GetLeagueRosterJoinRequestsUseCase,
@Inject(GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN)
private readonly getLeagueRosterMembersPresenter: GetLeagueRosterMembersPresenter,
@Inject(GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN)
private readonly getLeagueRosterJoinRequestsPresenter: GetLeagueRosterJoinRequestsPresenter,
) {}
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
@@ -247,43 +329,214 @@ export class LeagueService {
return this.totalLeaguesPresenter.getResponseModel()!;
}
private getActor(): ReturnType<typeof getActorFromRequestContext> {
return getActorFromRequestContext();
}
private async requireLeagueAdminPermissions(leagueId: string): Promise<void> {
await requireLeagueAdminOrOwner(leagueId, this.getLeagueAdminPermissionsUseCase);
}
async getLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestWithDriverDTO[]> {
this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`);
await this.requireLeagueAdminPermissions(leagueId);
this.leagueJoinRequestsPresenter.reset?.();
await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
return this.leagueJoinRequestsPresenter.getViewModel()!.joinRequests;
}
async approveLeagueJoinRequest(input: ApproveJoinRequestInputDTO): Promise<ApproveLeagueJoinRequestDTO> {
this.logger.debug('Approving join request:', input);
await this.approveLeagueJoinRequestUseCase.execute(input, this.approveLeagueJoinRequestPresenter);
await this.requireLeagueAdminPermissions(input.leagueId);
this.approveLeagueJoinRequestPresenter.reset?.();
const result = await this.approveLeagueJoinRequestUseCase.execute(
{ leagueId: input.leagueId, joinRequestId: input.requestId },
this.approveLeagueJoinRequestPresenter,
);
if (result.isErr()) {
const err = result.unwrapErr();
if (err.code === 'JOIN_REQUEST_NOT_FOUND') {
throw new NotFoundException('Join request not found');
}
if (err.code === 'LEAGUE_NOT_FOUND') {
throw new NotFoundException('League not found');
}
if (err.code === 'LEAGUE_AT_CAPACITY') {
throw new ConflictException('League is at capacity');
}
throw new Error(err.code);
}
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
}
async rejectLeagueJoinRequest(input: RejectJoinRequestInputDTO): Promise<RejectJoinRequestOutputDTO> {
this.logger.debug('Rejecting join request:', input);
await this.rejectLeagueJoinRequestUseCase.execute({
leagueId: input.leagueId,
adminId: 'admin', // This should come from auth context
requestId: input.requestId
});
await this.requireLeagueAdminPermissions(input.leagueId);
this.rejectLeagueJoinRequestPresenter.reset?.();
const result = await this.rejectLeagueJoinRequestUseCase.execute(
{ leagueId: input.leagueId, joinRequestId: input.requestId },
this.rejectLeagueJoinRequestPresenter,
);
if (result.isErr()) {
const err = result.unwrapErr();
if (err.code === 'JOIN_REQUEST_NOT_FOUND') {
throw new NotFoundException('Join request not found');
}
if (err.code === 'LEAGUE_NOT_FOUND') {
throw new NotFoundException('League not found');
}
if (err.code === 'LEAGUE_AT_CAPACITY') {
throw new ConflictException('League is at capacity');
}
throw new Error(err.code);
}
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
}
async approveLeagueRosterJoinRequest(leagueId: string, joinRequestId: string): Promise<ApproveLeagueJoinRequestDTO> {
this.logger.debug('Approving roster join request:', { leagueId, joinRequestId });
await this.requireLeagueAdminPermissions(leagueId);
this.approveLeagueJoinRequestPresenter.reset?.();
const result = await this.approveLeagueJoinRequestUseCase.execute(
{ leagueId, joinRequestId },
this.approveLeagueJoinRequestPresenter,
);
if (result.isErr()) {
const err = result.unwrapErr();
if (err.code === 'JOIN_REQUEST_NOT_FOUND') {
throw new NotFoundException('Join request not found');
}
if (err.code === 'LEAGUE_NOT_FOUND') {
throw new NotFoundException('League not found');
}
if (err.code === 'LEAGUE_AT_CAPACITY') {
throw new ConflictException('League is at capacity');
}
throw new Error(err.code);
}
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
}
async rejectLeagueRosterJoinRequest(leagueId: string, joinRequestId: string): Promise<RejectJoinRequestOutputDTO> {
this.logger.debug('Rejecting roster join request:', { leagueId, joinRequestId });
await this.requireLeagueAdminPermissions(leagueId);
this.rejectLeagueJoinRequestPresenter.reset?.();
const result = await this.rejectLeagueJoinRequestUseCase.execute(
{ leagueId, joinRequestId },
this.rejectLeagueJoinRequestPresenter,
);
if (result.isErr()) {
throw new NotFoundException('Join request not found');
}
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
}
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<LeagueAdminPermissionsDTO> {
this.logger.debug('Getting league admin permissions', { query });
await this.getLeagueAdminPermissionsUseCase.execute(query);
const actor = this.getActor();
this.logger.debug('Getting league admin permissions', { leagueId: query.leagueId, performerDriverId: actor.driverId });
await this.getLeagueAdminPermissionsUseCase.execute({
leagueId: query.leagueId,
performerDriverId: actor.driverId,
});
return this.getLeagueAdminPermissionsPresenter.getResponseModel()!;
}
async removeLeagueMember(input: RemoveLeagueMemberInputDTO): Promise<RemoveLeagueMemberOutputDTO> {
this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId });
await this.removeLeagueMemberUseCase.execute(input);
await this.requireLeagueAdminPermissions(input.leagueId);
this.removeLeagueMemberPresenter.reset?.();
const result = await this.removeLeagueMemberUseCase.execute({
leagueId: input.leagueId,
targetDriverId: input.targetDriverId,
});
if (result.isErr()) {
const err = result.unwrapErr();
if (err.code === 'MEMBERSHIP_NOT_FOUND') {
throw new NotFoundException('Member not found');
}
if (err.code === 'CANNOT_REMOVE_LAST_OWNER') {
throw new BadRequestException(err.details.message);
}
throw new Error(err.code);
}
return this.removeLeagueMemberPresenter.getViewModel()!;
}
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInputDTO): Promise<UpdateLeagueMemberRoleOutputDTO> {
this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
await this.updateLeagueMemberRoleUseCase.execute(input);
async updateLeagueMemberRole(
leagueId: string,
targetDriverId: string,
input: UpdateLeagueMemberRoleInputDTO,
): Promise<UpdateLeagueMemberRoleOutputDTO> {
this.logger.debug('Updating league member role', {
leagueId,
targetDriverId,
newRole: input.newRole,
});
await this.requireLeagueAdminPermissions(leagueId);
this.updateLeagueMemberRolePresenter.reset?.();
const result = await this.updateLeagueMemberRoleUseCase.execute({
leagueId,
targetDriverId,
newRole: input.newRole,
});
if (result.isErr()) {
const err = result.unwrapErr();
if (err.code === 'MEMBERSHIP_NOT_FOUND') {
throw new NotFoundException('Member not found');
}
if (err.code === 'INVALID_ROLE' || err.code === 'CANNOT_DOWNGRADE_LAST_OWNER') {
throw new BadRequestException(err.details.message);
}
throw new Error(err.code);
}
return this.updateLeagueMemberRolePresenter.getViewModel()!;
}
@@ -323,19 +576,166 @@ export class LeagueService {
return this.getLeagueMembershipsPresenter.getViewModel()!.memberships;
}
async getLeagueRosterMembers(leagueId: string): Promise<LeagueRosterMemberDTO[]> {
this.logger.debug('Getting league roster members (admin)', { leagueId });
await this.requireLeagueAdminPermissions(leagueId);
this.getLeagueRosterMembersPresenter.reset?.();
const result = await this.getLeagueRosterMembersUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.getLeagueRosterMembersPresenter.getViewModel()!;
}
async getLeagueRosterJoinRequests(leagueId: string): Promise<LeagueRosterJoinRequestDTO[]> {
this.logger.debug('Getting league roster join requests (admin)', { leagueId });
await this.requireLeagueAdminPermissions(leagueId);
this.getLeagueRosterJoinRequestsPresenter.reset?.();
const result = await this.getLeagueRosterJoinRequestsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.getLeagueRosterJoinRequestsPresenter.getViewModel()!;
}
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsDTO> {
this.logger.debug('Getting league standings', { leagueId });
await this.getLeagueStandingsUseCase.execute({ leagueId });
return this.leagueStandingsPresenter.getResponseModel()!;
}
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
this.logger.debug('Getting league schedule', { leagueId });
async getLeagueSchedule(leagueId: string, query?: GetLeagueScheduleQueryDTO): Promise<LeagueScheduleDTO> {
this.logger.debug('Getting league schedule', { leagueId, query });
const input: GetLeagueScheduleInput = query?.seasonId ? { leagueId, seasonId: query.seasonId } : { leagueId };
await this.getLeagueScheduleUseCase.execute(input);
await this.getLeagueScheduleUseCase.execute({ leagueId });
return this.leagueSchedulePresenter.getViewModel()!;
}
async publishLeagueSeasonSchedule(
leagueId: string,
seasonId: string,
_input: LeagueSeasonSchedulePublishInputDTO,
): Promise<LeagueSeasonSchedulePublishOutputDTO> {
void _input;
await this.requireLeagueAdminPermissions(leagueId);
this.publishLeagueSeasonSchedulePresenter.reset?.();
const result = await this.publishLeagueSeasonScheduleUseCase.execute({ leagueId, seasonId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.publishLeagueSeasonSchedulePresenter.getResponseModel()!;
}
async unpublishLeagueSeasonSchedule(
leagueId: string,
seasonId: string,
_input: LeagueSeasonSchedulePublishInputDTO,
): Promise<LeagueSeasonSchedulePublishOutputDTO> {
void _input;
await this.requireLeagueAdminPermissions(leagueId);
this.unpublishLeagueSeasonSchedulePresenter.reset?.();
const result = await this.unpublishLeagueSeasonScheduleUseCase.execute({ leagueId, seasonId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.unpublishLeagueSeasonSchedulePresenter.getResponseModel()!;
}
async createLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
input: CreateLeagueScheduleRaceInputDTO,
): Promise<CreateLeagueScheduleRaceOutputDTO> {
await this.requireLeagueAdminPermissions(leagueId);
const scheduledAt = new Date(input.scheduledAtIso);
if (Number.isNaN(scheduledAt.getTime())) {
throw new Error('INVALID_SCHEDULED_AT');
}
this.createLeagueSeasonScheduleRacePresenter.reset?.();
const result = await this.createLeagueSeasonScheduleRaceUseCase.execute({
leagueId,
seasonId,
track: input.track,
car: input.car,
scheduledAt,
});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.createLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
async updateLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
raceId: string,
input: UpdateLeagueScheduleRaceInputDTO,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
await this.requireLeagueAdminPermissions(leagueId);
const scheduledAt =
input.scheduledAtIso !== undefined ? new Date(input.scheduledAtIso) : undefined;
if (scheduledAt && Number.isNaN(scheduledAt.getTime())) {
throw new Error('INVALID_SCHEDULED_AT');
}
this.updateLeagueSeasonScheduleRacePresenter.reset?.();
const result = await this.updateLeagueSeasonScheduleRaceUseCase.execute({
leagueId,
seasonId,
raceId,
...(input.track !== undefined ? { track: input.track } : {}),
...(input.car !== undefined ? { car: input.car } : {}),
...(scheduledAt !== undefined ? { scheduledAt } : {}),
});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.updateLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
async deleteLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
raceId: string,
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
await this.requireLeagueAdminPermissions(leagueId);
this.deleteLeagueSeasonScheduleRacePresenter.reset?.();
const result = await this.deleteLeagueSeasonScheduleRaceUseCase.execute({ leagueId, seasonId, raceId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return this.deleteLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
this.logger.debug('Getting league stats', { leagueId });
await this.getLeagueStatsUseCase.execute({ leagueId });
@@ -408,17 +808,27 @@ export class LeagueService {
return this.leagueScoringPresetsPresenter.getViewModel()!;
}
async joinLeague(leagueId: string, driverId: string): Promise<JoinLeagueOutputDTO> {
this.logger.debug('Joining league', { leagueId, driverId });
async joinLeague(leagueId: string): Promise<JoinLeagueOutputDTO> {
const actor = this.getActor();
this.logger.debug('Joining league', { leagueId, actorDriverId: actor.driverId });
await this.joinLeagueUseCase.execute({ leagueId, driverId });
await this.joinLeagueUseCase.execute({ leagueId, driverId: actor.driverId });
return this.joinLeaguePresenter.getViewModel()!;
}
async transferLeagueOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<TransferLeagueOwnershipOutputDTO> {
this.logger.debug('Transferring league ownership', { leagueId, currentOwnerId, newOwnerId });
async transferLeagueOwnership(leagueId: string, input: TransferLeagueOwnershipInputDTO): Promise<TransferLeagueOwnershipOutputDTO> {
this.logger.debug('Transferring league ownership', { leagueId, newOwnerId: input.newOwnerId });
await this.requireLeagueAdminPermissions(leagueId);
const actor = this.getActor();
await this.transferLeagueOwnershipUseCase.execute({
leagueId,
currentOwnerId: actor.driverId,
newOwnerId: input.newOwnerId,
});
await this.transferLeagueOwnershipUseCase.execute({ leagueId, currentOwnerId, newOwnerId });
return this.transferLeagueOwnershipPresenter.getViewModel()!;
}
@@ -444,15 +854,22 @@ export class LeagueService {
return this.getLeagueWalletPresenter.getResponseModel();
}
async withdrawFromLeagueWallet(leagueId: string, input: WithdrawFromLeagueWalletInputDTO): Promise<WithdrawFromLeagueWalletOutputDTO> {
async withdrawFromLeagueWallet(
leagueId: string,
input: WithdrawFromLeagueWalletInputDTO,
): Promise<WithdrawFromLeagueWalletOutputDTO> {
this.logger.debug('Withdrawing from league wallet', { leagueId, amount: input.amount });
const actor = this.getActor();
await this.withdrawFromLeagueWalletUseCase.execute({
leagueId,
requestedById: "admin",
requestedById: actor.driverId,
amount: input.amount,
currency: input.currency as 'USD' | 'EUR' | 'GBP',
reason: input.destinationAccount,
});
return this.withdrawFromLeagueWalletPresenter.getResponseModel();
}
}

View File

@@ -33,12 +33,21 @@ export const GET_LEAGUE_OWNER_SUMMARY_USE_CASE = 'GetLeagueOwnerSummaryUseCase';
export const GET_LEAGUE_PROTESTS_USE_CASE = 'GetLeagueProtestsUseCase';
export const GET_LEAGUE_SEASONS_USE_CASE = 'GetLeagueSeasonsUseCase';
export const GET_LEAGUE_MEMBERSHIPS_USE_CASE = 'GetLeagueMembershipsUseCase';
export const GET_LEAGUE_ROSTER_MEMBERS_USE_CASE = 'GetLeagueRosterMembersUseCase';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE = 'GetLeagueRosterJoinRequestsUseCase';
export const GET_LEAGUE_SCHEDULE_USE_CASE = 'GetLeagueScheduleUseCase';
export const GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE = 'GetLeagueAdminPermissionsUseCase';
export const GET_LEAGUE_WALLET_USE_CASE = 'GetLeagueWalletUseCase';
export const WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE = 'WithdrawFromLeagueWalletUseCase';
export const GET_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase';
// Schedule mutation use cases
export const CREATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE = 'CreateLeagueSeasonScheduleRaceUseCase';
export const UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE = 'UpdateLeagueSeasonScheduleRaceUseCase';
export const DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE = 'DeleteLeagueSeasonScheduleRaceUseCase';
export const PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE = 'PublishLeagueSeasonScheduleUseCase';
export const UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE = 'UnpublishLeagueSeasonScheduleUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN';
export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityAndScoringOutputPort_TOKEN';
export const GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN';
@@ -49,6 +58,8 @@ export const APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'ApproveLeagueJoinR
export const CREATE_LEAGUE_OUTPUT_PORT_TOKEN = 'CreateLeagueOutputPort_TOKEN';
export const GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN = 'GetLeagueAdminPermissionsOutputPort_TOKEN';
export const GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN = 'GetLeagueMembershipsOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterMembersOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterJoinRequestsOutputPort_TOKEN';
export const GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN = 'GetLeagueOwnerSummaryOutputPort_TOKEN';
export const GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN = 'GetLeagueSeasonsOutputPort_TOKEN';
export const JOIN_LEAGUE_OUTPUT_PORT_TOKEN = 'JoinLeagueOutputPort_TOKEN';
@@ -62,4 +73,11 @@ export const UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN = 'UpdateLeagueMemberRo
export const GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueFullConfigOutputPort_TOKEN';
export const GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueScoringConfigOutputPort_TOKEN';
export const GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'GetLeagueWalletOutputPort_TOKEN';
export const WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'WithdrawFromLeagueWalletOutputPort_TOKEN';
export const WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'WithdrawFromLeagueWalletOutputPort_TOKEN';
// Schedule mutation output ports
export const CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'CreateLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'UpdateLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'DeleteLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN = 'PublishLeagueSeasonScheduleOutputPort_TOKEN';
export const UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN = 'UnpublishLeagueSeasonScheduleOutputPort_TOKEN';

View File

@@ -5,8 +5,4 @@ export class GetLeagueAdminPermissionsInputDTO {
@ApiProperty()
@IsString()
leagueId!: string;
@ApiProperty()
@IsString()
performerDriverId!: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class GetLeagueScheduleQueryDTO {
@ApiPropertyOptional({ description: 'Season to scope schedule to' })
@IsOptional()
@IsString()
seasonId?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsDate, IsEnum, ValidateNested } from 'class-validator';
import { IsEnum, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { DriverDTO } from '../../driver/dtos/DriverDTO';
@@ -13,12 +13,11 @@ export class LeagueMemberDTO {
@Type(() => DriverDTO)
driver!: DriverDTO;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@IsEnum(['owner', 'manager', 'member'])
role!: 'owner' | 'manager' | 'member';
@ApiProperty({ enum: ['owner', 'admin', 'steward', 'member'] })
@IsEnum(['owner', 'admin', 'steward', 'member'])
role!: 'owner' | 'admin' | 'steward' | 'member';
@ApiProperty()
@IsDate()
@Type(() => Date)
joinedAt!: Date;
@ApiProperty({ description: 'ISO-8601 timestamp' })
@IsString()
joinedAt!: string;
}

View File

@@ -0,0 +1,41 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsOptional, IsString, ValidateNested } from 'class-validator';
class LeagueRosterJoinRequestDriverDTO {
@ApiProperty()
@IsString()
id!: string;
@ApiProperty()
@IsString()
name!: string;
}
export class LeagueRosterJoinRequestDTO {
@ApiProperty()
@IsString()
id!: string;
@ApiProperty()
@IsString()
leagueId!: string;
@ApiProperty()
@IsString()
driverId!: string;
@ApiProperty({ format: 'date-time' })
@IsString()
requestedAt!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
message?: string;
@ApiProperty({ type: () => LeagueRosterJoinRequestDriverDTO })
@ValidateNested()
@Type(() => LeagueRosterJoinRequestDriverDTO)
driver!: LeagueRosterJoinRequestDriverDTO;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsString, ValidateNested } from 'class-validator';
import { DriverDTO } from '../../driver/dtos/DriverDTO';
export class LeagueRosterMemberDTO {
@ApiProperty()
@IsString()
driverId!: string;
@ApiProperty({ type: () => DriverDTO })
@ValidateNested()
@Type(() => DriverDTO)
driver!: DriverDTO;
@ApiProperty({ enum: ['owner', 'admin', 'steward', 'member'] })
@IsEnum(['owner', 'admin', 'steward', 'member'])
role!: 'owner' | 'admin' | 'steward' | 'member';
@ApiProperty({ format: 'date-time' })
@IsString()
joinedAt!: string;
}

View File

@@ -1,9 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { RaceDTO } from '../../race/dtos/RaceDTO';
export class LeagueScheduleDTO {
@ApiProperty()
@IsString()
seasonId!: string;
@ApiProperty({ description: 'Whether the season schedule is published' })
@IsBoolean()
published!: boolean;
@ApiProperty({ type: [RaceDTO] })
@IsArray()
@ValidateNested({ each: true })

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsISO8601, IsOptional, IsString } from 'class-validator';
export class CreateLeagueScheduleRaceInputDTO {
@ApiProperty()
@IsString()
track!: string;
@ApiProperty()
@IsString()
car!: string;
@ApiProperty({
description: 'ISO-8601 timestamp string (UTC recommended).',
example: '2025-01-01T12:00:00.000Z',
})
@IsISO8601()
scheduledAtIso!: string;
}
export class UpdateLeagueScheduleRaceInputDTO {
@ApiPropertyOptional()
@IsOptional()
@IsString()
track?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
car?: string;
@ApiPropertyOptional({
description: 'ISO-8601 timestamp string (UTC recommended).',
example: '2025-01-01T12:00:00.000Z',
})
@IsOptional()
@IsISO8601()
scheduledAtIso?: string;
}
export class CreateLeagueScheduleRaceOutputDTO {
@ApiProperty()
@IsString()
raceId!: string;
}
export class LeagueScheduleRaceMutationSuccessDTO {
@ApiProperty()
@IsBoolean()
success!: boolean;
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
/**
* Intentionally empty.
*
* With global ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
* any unexpected body keys will be rejected (prevents identity spoofing / junk payloads).
*/
export class LeagueSeasonSchedulePublishInputDTO {}
export class LeagueSeasonSchedulePublishOutputDTO {
@ApiProperty()
@IsBoolean()
success!: boolean;
@ApiProperty({ description: 'Whether the season schedule is published after this operation' })
@IsBoolean()
published!: boolean;
}

View File

@@ -6,10 +6,6 @@ export class RemoveLeagueMemberInputDTO {
@IsString()
leagueId!: string;
@ApiProperty()
@IsString()
performerDriverId!: string;
@ApiProperty()
@IsString()
targetDriverId!: string;

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class TransferLeagueOwnershipInputDTO {
@ApiProperty()
@IsString()
newOwnerId!: string;
}

View File

@@ -1,20 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum } from 'class-validator';
import { IsEnum } from 'class-validator';
export class UpdateLeagueMemberRoleInputDTO {
@ApiProperty()
@IsString()
leagueId!: string;
@ApiProperty()
@IsString()
performerDriverId!: string;
@ApiProperty()
@IsString()
targetDriverId!: string;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@IsEnum(['owner', 'manager', 'member'])
newRole!: 'owner' | 'manager' | 'member';
@ApiProperty({ enum: ['owner', 'admin', 'steward', 'member'] })
@IsEnum(['owner', 'admin', 'steward', 'member'])
newRole!: 'owner' | 'admin' | 'steward' | 'member';
}

View File

@@ -27,8 +27,8 @@ export class GetLeagueMembershipsPresenter implements UseCaseOutputPort<GetLeagu
joinedAt: driver!.joinedAt.toDate().toISOString(),
...(driver!.bio ? { bio: driver!.bio.toString() } : {}),
},
role: membership.role.toString() as 'owner' | 'manager' | 'member',
joinedAt: membership.joinedAt.toDate(),
role: membership.role.toString() as 'owner' | 'admin' | 'steward' | 'member',
joinedAt: membership.joinedAt.toDate().toISOString(),
}));
this.result = {
memberships: {

View File

@@ -0,0 +1,64 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetLeagueRosterMembersResult } from '@core/racing/application/use-cases/GetLeagueRosterMembersUseCase';
import type { GetLeagueRosterJoinRequestsResult } from '@core/racing/application/use-cases/GetLeagueRosterJoinRequestsUseCase';
import type { LeagueRosterMemberDTO } from '../dtos/LeagueRosterMemberDTO';
import type { LeagueRosterJoinRequestDTO } from '../dtos/LeagueRosterJoinRequestDTO';
import type { DriverDTO } from '../../driver/dtos/DriverDTO';
export class GetLeagueRosterMembersPresenter implements UseCaseOutputPort<GetLeagueRosterMembersResult> {
private viewModel: LeagueRosterMemberDTO[] | null = null;
reset(): void {
this.viewModel = null;
}
present(result: GetLeagueRosterMembersResult): void {
this.viewModel = result.members.map(({ membership, driver }) => ({
driverId: membership.driverId.toString(),
driver: this.mapDriver(driver),
role: membership.role.toString() as 'owner' | 'admin' | 'steward' | 'member',
joinedAt: membership.joinedAt.toDate().toISOString(),
}));
}
getViewModel(): LeagueRosterMemberDTO[] | null {
return this.viewModel;
}
private mapDriver(driver: GetLeagueRosterMembersResult['members'][number]['driver']): DriverDTO {
return {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
...(driver.bio ? { bio: driver.bio.toString() } : {}),
};
}
}
export class GetLeagueRosterJoinRequestsPresenter implements UseCaseOutputPort<GetLeagueRosterJoinRequestsResult> {
private viewModel: LeagueRosterJoinRequestDTO[] | null = null;
reset(): void {
this.viewModel = null;
}
present(result: GetLeagueRosterJoinRequestsResult): void {
this.viewModel = result.joinRequests.map(req => ({
id: req.id,
leagueId: req.leagueId,
driverId: req.driverId,
requestedAt: req.requestedAt.toISOString(),
...(req.message ? { message: req.message } : {}),
driver: {
id: req.driver.id,
name: req.driver.name.toString(),
},
}));
}
getViewModel(): LeagueRosterJoinRequestDTO[] | null {
return this.viewModel;
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { LeagueSchedulePresenter } from './LeagueSchedulePresenter';
import { Race } from '@core/racing/domain/entities/Race';
describe('LeagueSchedulePresenter', () => {
it('includes seasonId on the schedule DTO and serializes dates to ISO strings', () => {
const presenter = new LeagueSchedulePresenter();
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
track: 'Spa',
car: 'GT3',
});
presenter.present({
league: { id: 'league-1' },
seasonId: 'season-1',
published: false,
races: [{ race }],
} as any);
const vm = presenter.getViewModel() as any;
expect(vm).not.toBeNull();
expect(vm.seasonId).toBe('season-1');
expect(vm.published).toBe(false);
expect(Array.isArray(vm.races)).toBe(true);
expect(vm.races[0]).toMatchObject({
id: 'race-1',
name: 'Spa - GT3',
date: '2025-01-02T20:00:00.000Z',
});
// Guard: dates must be ISO strings (no Date objects)
expect(typeof vm.races[0].date).toBe('string');
expect(vm.races[0].date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});

View File

@@ -12,6 +12,8 @@ export class LeagueSchedulePresenter implements UseCaseOutputPort<GetLeagueSched
present(result: GetLeagueScheduleResult, leagueName?: string) {
this.result = {
seasonId: result.seasonId,
published: result.published,
races: result.races.map(race => ({
id: race.race.id,
name: `${race.race.track} - ${race.race.car}`,

View File

@@ -0,0 +1,105 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type {
CreateLeagueScheduleRaceOutputDTO,
LeagueScheduleRaceMutationSuccessDTO,
} from '../dtos/LeagueScheduleRaceAdminDTO';
import type { LeagueSeasonSchedulePublishOutputDTO } from '../dtos/LeagueSeasonSchedulePublishDTO';
import type { CreateLeagueSeasonScheduleRaceResult } from '@core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase';
import type { UpdateLeagueSeasonScheduleRaceResult } from '@core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase';
import type { DeleteLeagueSeasonScheduleRaceResult } from '@core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase';
import type { PublishLeagueSeasonScheduleResult } from '@core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase';
import type { UnpublishLeagueSeasonScheduleResult } from '@core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase';
export class CreateLeagueSeasonScheduleRacePresenter
implements UseCaseOutputPort<CreateLeagueSeasonScheduleRaceResult>
{
private responseModel: CreateLeagueScheduleRaceOutputDTO | null = null;
present(result: CreateLeagueSeasonScheduleRaceResult): void {
this.responseModel = { raceId: result.raceId };
}
getResponseModel(): CreateLeagueScheduleRaceOutputDTO | null {
return this.responseModel;
}
reset(): void {
this.responseModel = null;
}
}
export class UpdateLeagueSeasonScheduleRacePresenter
implements UseCaseOutputPort<UpdateLeagueSeasonScheduleRaceResult>
{
private responseModel: LeagueScheduleRaceMutationSuccessDTO | null = null;
present(result: UpdateLeagueSeasonScheduleRaceResult): void {
void result;
this.responseModel = { success: true };
}
getResponseModel(): LeagueScheduleRaceMutationSuccessDTO | null {
return this.responseModel;
}
reset(): void {
this.responseModel = null;
}
}
export class DeleteLeagueSeasonScheduleRacePresenter
implements UseCaseOutputPort<DeleteLeagueSeasonScheduleRaceResult>
{
private responseModel: LeagueScheduleRaceMutationSuccessDTO | null = null;
present(result: DeleteLeagueSeasonScheduleRaceResult): void {
void result;
this.responseModel = { success: true };
}
getResponseModel(): LeagueScheduleRaceMutationSuccessDTO | null {
return this.responseModel;
}
reset(): void {
this.responseModel = null;
}
}
export class PublishLeagueSeasonSchedulePresenter
implements UseCaseOutputPort<PublishLeagueSeasonScheduleResult>
{
private responseModel: LeagueSeasonSchedulePublishOutputDTO | null = null;
present(result: PublishLeagueSeasonScheduleResult): void {
this.responseModel = { success: true, published: result.published };
}
getResponseModel(): LeagueSeasonSchedulePublishOutputDTO | null {
return this.responseModel;
}
reset(): void {
this.responseModel = null;
}
}
export class UnpublishLeagueSeasonSchedulePresenter
implements UseCaseOutputPort<UnpublishLeagueSeasonScheduleResult>
{
private responseModel: LeagueSeasonSchedulePublishOutputDTO | null = null;
present(result: UnpublishLeagueSeasonScheduleResult): void {
this.responseModel = { success: true, published: result.published };
}
getResponseModel(): LeagueSeasonSchedulePublishOutputDTO | null {
return this.responseModel;
}
reset(): void {
this.responseModel = null;
}
}

View File

@@ -10,11 +10,9 @@ export class RejectLeagueJoinRequestPresenter implements UseCaseOutputPort<Rejec
}
present(result: RejectLeagueJoinRequestResult): void {
void result;
this.result = {
success: true,
message: 'Join request rejected successfully',
success: result.success,
message: result.message,
};
}

View File

@@ -24,10 +24,13 @@ export class GetAllRacesPresenter implements UseCaseOutputPort<GetAllRacesResult
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
status: race.status.toString(),
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField ?? null,
strengthOfField:
typeof race.strengthOfField === 'number'
? race.strengthOfField
: race.strengthOfField?.toNumber() ?? null,
})),
filters: {
statuses: [

View File

@@ -42,9 +42,13 @@ export class RaceDetailPresenter implements UseCaseOutputPort<GetRaceDetailResul
scheduledAt: output.race.scheduledAt.toISOString(),
sessionType: output.race.sessionType.toString(),
status: output.race.status.toString(),
strengthOfField: output.race.strengthOfField ?? null,
...(output.race.registeredCount !== undefined && { registeredCount: output.race.registeredCount }),
...(output.race.maxParticipants !== undefined && { maxParticipants: output.race.maxParticipants }),
strengthOfField: output.race.strengthOfField?.toNumber() ?? null,
...(output.race.registeredCount !== undefined && {
registeredCount: output.race.registeredCount.toNumber(),
}),
...(output.race.maxParticipants !== undefined && {
maxParticipants: output.race.maxParticipants.toNumber(),
}),
}
: null;

View File

@@ -36,13 +36,13 @@ export class RacesPageDataPresenter {
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
status: race.status.toString(),
leagueId: race.leagueId,
leagueName,
strengthOfField: race.strengthOfField ?? null,
strengthOfField: race.strengthOfField?.toNumber() ?? null,
isUpcoming: race.scheduledAt > new Date(),
isLive: race.status === 'running',
isPast: race.scheduledAt < new Date() && race.status === 'completed',
isLive: race.status.isRunning(),
isPast: race.scheduledAt < new Date() && race.status.isCompleted(),
}));
this.model = { races } as RacesPageDataDTO;

View File

@@ -8,6 +8,9 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
interface OpenAPISchema {
type?: string;
@@ -38,6 +41,7 @@ describe('API Contract Validation', () => {
const apiRoot = path.join(__dirname, '../../..');
const openapiPath = path.join(apiRoot, 'openapi.json');
const generatedTypesDir = path.join(apiRoot, '../website/lib/types/generated');
const execFileAsync = promisify(execFile);
describe('OpenAPI Spec Integrity', () => {
it('should have a valid OpenAPI spec file', async () => {
@@ -53,7 +57,7 @@ describe('API Contract Validation', () => {
it('should have required OpenAPI fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
expect(spec.info).toBeDefined();
expect(spec.info.title).toBeDefined();
@@ -62,6 +66,105 @@ describe('API Contract Validation', () => {
expect(spec.components.schemas).toBeDefined();
});
it('committed openapi.json should match generator output', async () => {
const repoRoot = path.join(apiRoot, '../..');
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');
await execFileAsync(
'npx',
['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
{ cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
);
const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));
expect(generated).toEqual(committed);
});
it('should include real HTTP paths for known routes', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pathKeys = Object.keys(spec.paths ?? {});
expect(pathKeys.length).toBeGreaterThan(0);
// A couple of stable routes to detect "empty/stale" specs.
expect(spec.paths['/drivers/leaderboard']).toBeDefined();
expect(spec.paths['/dashboard/overview']).toBeDefined();
// Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
expect(spec.paths['/dashboard/overview'].get).toBeDefined();
});
it('should include league schedule publish/unpublish endpoints and published state', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();
const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
if (!scheduleSchema) {
throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
}
expect(scheduleSchema.properties?.published).toBeDefined();
expect(scheduleSchema.properties?.published?.type).toBe('boolean');
expect(scheduleSchema.required ?? []).toContain('published');
});
it('should include league roster admin read endpoints and schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();
const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
if (!memberSchema) {
throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
}
expect(memberSchema.properties?.driverId).toBeDefined();
expect(memberSchema.properties?.role).toBeDefined();
expect(memberSchema.properties?.joinedAt).toBeDefined();
expect(memberSchema.required ?? []).toContain('driverId');
expect(memberSchema.required ?? []).toContain('role');
expect(memberSchema.required ?? []).toContain('joinedAt');
expect(memberSchema.required ?? []).toContain('driver');
const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
if (!joinRequestSchema) {
throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
}
expect(joinRequestSchema.properties?.id).toBeDefined();
expect(joinRequestSchema.properties?.leagueId).toBeDefined();
expect(joinRequestSchema.properties?.driverId).toBeDefined();
expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
expect(joinRequestSchema.required ?? []).toContain('id');
expect(joinRequestSchema.required ?? []).toContain('leagueId');
expect(joinRequestSchema.required ?? []).toContain('driverId');
expect(joinRequestSchema.required ?? []).toContain('requestedAt');
expect(joinRequestSchema.required ?? []).toContain('driver');
});
it('should have no circular references in schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
@@ -113,19 +216,30 @@ describe('API Contract Validation', () => {
});
describe('DTO Consistency', () => {
it('should have DTO files for all schemas', async () => {
it('should have generated DTO files for critical schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = Object.keys(spec.components.schemas);
const generatedFiles = await fs.readdir(generatedTypesDir);
const generatedDTOs = generatedFiles
.filter(f => f.endsWith('.ts'))
.map(f => f.replace('.ts', ''));
// All schemas should have corresponding generated DTOs
for (const schema of schemas) {
expect(generatedDTOs).toContain(schema);
// We intentionally do NOT require a 1:1 mapping for *all* schemas here.
// OpenAPI generation and type generation can be run as separate steps,
// and new schemas should not break API contract validation by themselves.
const criticalDTOs = [
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO',
'UploadMediaInputDTO',
'UploadMediaOutputDTO',
'RaceDTO',
'DriverDTO',
];
for (const dtoName of criticalDTOs) {
expect(spec.components.schemas[dtoName]).toBeDefined();
expect(generatedDTOs).toContain(dtoName);
}
});
@@ -166,12 +280,19 @@ describe('API Contract Validation', () => {
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
// Basic TypeScript syntax checks
// `index.ts` is a generated barrel file (no interfaces).
if (file === 'index.ts') {
expect(content).toContain('export type {');
expect(content).toContain("from './");
continue;
}
// Basic TypeScript syntax checks (DTO interfaces)
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
// Should not have syntax errors (basic check)
expect(content).not.toContain('undefined;');
expect(content).not.toContain('any;');
@@ -233,25 +354,16 @@ describe('API Contract Validation', () => {
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
for (const [schemaName, schema] of Object.entries(schemas)) {
for (const [, schema] of Object.entries(schemas)) {
const required = new Set(schema.required ?? []);
if (!schema.properties) continue;
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
if (!propSchema.nullable) continue;
if (propSchema.nullable) {
// Nullable properties should be optional OR include `| null` in the type.
const propRegex = new RegExp(`${propName}(\\?)?:\\s*([^;]+);`);
const match = dtoContent.match(propRegex);
if (match) {
const optionalMark = match[1];
const typeText = match[2] ?? '';
expect(optionalMark === '?' || typeText.includes('| null')).toBe(true);
}
}
// In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
// otherwise downstream generators can't represent it safely.
expect(required.has(propName)).toBe(false);
}
}
});

View File

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

View File

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

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

View File

@@ -0,0 +1,7 @@
'use client';
import { RosterAdminPage } from './RosterAdminPage';
export default function LeagueRosterAdminPage() {
return <RosterAdminPage />;
}

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,6 @@ export class ProtestDecisionCommandModel {
raceId,
driverId,
stewardId,
enum: this.penaltyType,
type: this.penaltyType,
reason,
protestId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More