This commit is contained in:
2025-12-14 18:11:59 +01:00
parent acc15e8d8d
commit 217337862c
91 changed files with 5919 additions and 1999 deletions

View File

@@ -5,6 +5,7 @@
* This creates an active sponsorship and notifies the sponsor.
*/
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
@@ -31,56 +32,73 @@ export class AcceptSponsorshipRequestUseCase
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly logger: ILogger,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
throw new Error('Sponsorship request not found');
}
if (!request.isPending()) {
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
}
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
throw new Error('Season not found for sponsorship request');
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
try {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
throw new Error('Sponsorship request not found');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
if (!request.isPending()) {
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status });
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
}
this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId });
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId });
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType });
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId });
throw new Error('Season not found for sponsorship request');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
}
// TODO: In a real implementation, we would:
// 1. Create notification for the sponsor
// 2. Process payment
// 3. Update wallet balances
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
return {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
};
} catch (error: any) {
this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${error.message}`, { requestId: dto.requestId, error: error.message, stack: error.stack });
throw error;
}
// TODO: In a real implementation, we would:
// 1. Create notification for the sponsor
// 2. Process payment
// 3. Update wallet balances
return {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
};
}
}
}

View File

@@ -16,6 +16,7 @@ import {
EntityNotFoundError,
BusinessRuleViolationError,
} from '../errors/RacingApplicationError';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ApplyForSponsorshipDTO {
sponsorId: string;
@@ -40,22 +41,28 @@ export class ApplyForSponsorshipUseCase
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly logger: ILogger,
) {}
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> {
this.logger.debug('Attempting to apply for sponsorship', { dto });
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
this.logger.error('Sponsor not found', { sponsorId: dto.sponsorId });
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId });
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing');
}
if (!pricing.acceptingApplications) {
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError(
'This entity is not currently accepting sponsorship applications',
);
@@ -64,6 +71,7 @@ export class ApplyForSponsorshipUseCase
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
throw new BusinessRuleViolationError(
`No ${dto.tier} sponsorship slots are available`,
);
@@ -76,6 +84,7 @@ export class ApplyForSponsorshipUseCase
dto.entityId,
);
if (hasPending) {
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError(
'You already have a pending sponsorship request for this entity',
);
@@ -84,6 +93,7 @@ export class ApplyForSponsorshipUseCase
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) {
this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`);
throw new BusinessRuleViolationError(
`Offered amount must be at least ${minPrice.format()}`,
);

View File

@@ -12,6 +12,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ApplyPenaltyCommand {
raceId: string;
@@ -31,57 +32,73 @@ export class ApplyPenaltyUseCase
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
throw new Error('Race not found');
}
// Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active'
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
throw new Error('Only league owners and admins can apply penalties');
}
// If linked to a protest, validate the protest exists and is upheld
if (command.protestId) {
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
try {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
throw new Error('Race not found');
}
if (protest.status !== 'upheld') {
throw new Error('Can only create penalties for upheld protests');
this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`);
// Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active'
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
throw new Error('Only league owners and admins can apply penalties');
}
if (protest.raceId !== command.raceId) {
throw new Error('Protest is not for this race');
this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`);
// If linked to a protest, validate the protest exists and is upheld
if (command.protestId) {
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
throw new Error('Protest not found');
}
if (protest.status !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
throw new Error('Can only create penalties for upheld protests');
}
if (protest.raceId !== command.raceId) {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`);
throw new Error('Protest is not for this race');
}
this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`);
}
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`);
return { penaltyId: penalty.id };
} catch (error) {
this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', { command, error: error.message });
throw error;
}
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
return { penaltyId: penalty.id };
}
}

View File

@@ -1,3 +1,4 @@
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
@@ -12,24 +13,31 @@ export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> {
const { requestId } = command;
this.logger.debug(
`Attempting to approve team join request with ID: ${requestId}`,
);
// There is no repository method to look up a single request by ID,
// so we rely on the repository implementation to surface all relevant
// requests via getJoinRequests and search by ID here.
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
// For the in-memory fake used in tests, the teamId argument is ignored
// and all requests are returned.
'' as string,
);
const request = allRequests.find((r) => r.id === requestId);
try {
// There is no repository method to look up a single request by ID,
// so we rely on the repository implementation to surface all relevant
// requests via getJoinRequests and search by ID here.
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
// For the in-memory fake used in tests, the teamId argument is ignored
// and all requests are returned.'
'' as string,
);
const request = allRequests.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
if (!request) {
this.logger.warn(`Team join request with ID ${requestId} not found`);
throw new Error('Join request not found');
}
const membership: TeamMembership = {
teamId: request.teamId,
@@ -40,6 +48,14 @@ export class ApproveTeamJoinRequestUseCase
};
await this.membershipRepository.saveMembership(membership);
this.logger.info(
`Team membership created for driver ${request.driverId} in team ${request.teamId} from request ${requestId}`,
);
await this.membershipRepository.removeJoinRequest(requestId);
this.logger.info(`Team join request with ID ${requestId} removed`);
} catch (error) {
this.logger.error(`Failed to approve team join request ${requestId}:`, error);
throw error;
}
}
}

View File

@@ -1,5 +1,6 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case: CancelRaceUseCase
@@ -18,17 +19,26 @@ export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: ILogger,
) {}
async execute(command: CancelRaceCommandDTO): Promise<void> {
const { raceId } = command;
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
throw new Error('Race not found');
}
const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace);
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
} catch (error) {
this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}:`, error);
throw error;
}
const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace);
}
}

View File

@@ -6,6 +6,7 @@ import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case: CompleteRaceUseCase
@@ -30,39 +31,55 @@ export class CompleteRaceUseCase
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly logger: ILogger,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
this.logger.debug(`Executing CompleteRaceUseCase for raceId: ${command.raceId}`);
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.error(`Race with id ${raceId} not found.`);
throw new Error('Race not found');
}
this.logger.debug(`Race ${raceId} found. Status: ${race.status}`);
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers found for race ${raceId}.`);
throw new Error('Cannot complete race with no registered drivers');
}
this.logger.info(`${registeredDriverIds.length} drivers registered for race ${raceId}. Generating results.`);
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug(`Driver ratings fetched for ${registeredDriverIds.length} drivers.`);
// Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.debug(`Generated ${results.length} race results for race ${raceId}.`);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
this.logger.info(`Persisted ${results.length} race results for race ${raceId}.`);
// Update standings
await this.updateStandings(race.leagueId, results);
this.logger.info(`Standings updated for league ${race.leagueId}.`);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
this.logger.info(`Race ${raceId} successfully completed and updated.`);
} catch (error) {
this.logger.error(`Failed to complete race ${raceId}: ${error.message}`, error as Error);
throw error;
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
throw new Error('Cannot complete race with no registered drivers');
}
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
// Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Update standings
await this.updateStandings(race.leagueId, results);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
}
private generateRaceResults(
@@ -70,6 +87,7 @@ export class CompleteRaceUseCase
driverIds: string[],
driverRatings: Map<string, number>
): Result[] {
this.logger.debug(`Generating race results for race ${raceId} with ${driverIds.length} drivers.`);
// Create driver performance data
const driverPerformances = driverIds.map(driverId => ({
driverId,
@@ -83,6 +101,7 @@ export class CompleteRaceUseCase
const perfB = b.rating + (b.randomFactor * 200);
return perfB - perfA; // Higher performance first
});
this.logger.debug(`Driver performances sorted for race ${raceId}.`);
// Generate qualifying results for start positions (similar but different from race results)
const qualiPerformances = driverPerformances.map(p => ({
@@ -94,6 +113,7 @@ export class CompleteRaceUseCase
const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA;
});
this.logger.debug(`Qualifying performances generated for race ${raceId}.`);
// Generate results
const results: Result[] = [];
@@ -123,11 +143,13 @@ export class CompleteRaceUseCase
})
);
}
this.logger.debug(`Individual results created for race ${raceId}.`);
return results;
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
this.logger.debug(`Updating standings for league ${leagueId} with ${results.length} results.`);
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
@@ -135,6 +157,7 @@ export class CompleteRaceUseCase
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
this.logger.debug(`Results grouped by driver for league ${leagueId}.`);
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
@@ -145,6 +168,9 @@ export class CompleteRaceUseCase
leagueId,
driverId,
});
this.logger.debug(`Created new standing for driver ${driverId} in league ${leagueId}.`);
} else {
this.logger.debug(`Found existing standing for driver ${driverId} in league ${leagueId}.`);
}
// Add all results for this driver (should be just one for this race)
@@ -155,6 +181,8 @@ export class CompleteRaceUseCase
}
await this.standingRepository.save(standing);
this.logger.debug(`Standing saved for driver ${driverId} in league ${leagueId}.`);
}
this.logger.info(`Standings update complete for league ${leagueId}.`);
}
}

View File

@@ -8,6 +8,7 @@ import { Standing } from '../../domain/entities/Standing';
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
import { RatingUpdateService } from '@gridpilot/identity/domain/services/RatingUpdateService';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Enhanced CompleteRaceUseCase that includes rating updates
@@ -25,42 +26,65 @@ export class CompleteRaceUseCaseWithRatings
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService,
private readonly logger: ILogger,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
const { raceId } = command;
this.logger.debug(`Attempting to complete race with ID: ${raceId}`);
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.error(`Race not found for ID: ${raceId}`);
throw new Error('Race not found');
}
this.logger.debug(`Found race: ${race.id}`);
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers for race ID: ${raceId}. Cannot complete race.`);
throw new Error('Cannot complete race with no registered drivers');
}
this.logger.debug(`Found ${registeredDriverIds.length} registered drivers for race ID: ${raceId}`);
// Get driver ratings
this.logger.debug('Fetching driver ratings...');
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug('Driver ratings fetched.');
// Generate realistic race results
this.logger.debug('Generating race results...');
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.info(`Generated ${results.length} race results for race ID: ${raceId}`);
// Save results
this.logger.debug('Saving race results...');
for (const result of results) {
await this.resultRepository.create(result);
}
this.logger.info('Race results saved successfully.');
// Update standings
this.logger.debug(`Updating standings for league ID: ${race.leagueId}`);
await this.updateStandings(race.leagueId, results);
this.logger.info('Standings updated successfully.');
// Update driver ratings based on performance
this.logger.debug('Updating driver ratings...');
await this.updateDriverRatings(results, registeredDriverIds.length);
this.logger.info('Driver ratings updated successfully.');
// Complete the race
this.logger.debug(`Marking race ID: ${raceId} as complete...`);
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
this.logger.info(`Race ID: ${raceId} completed successfully.`);
} catch (error: any) {
this.logger.error(`Error completing race ${raceId}: ${error.message}`);
throw error;
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
throw new Error('Cannot complete race with no registered drivers');
}
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
// Generate realistic race results
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Update standings
await this.updateStandings(race.leagueId, results);
// Update driver ratings based on performance
await this.updateDriverRatings(results, registeredDriverIds.length);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
@@ -105,4 +129,4 @@ export class CompleteRaceUseCaseWithRatings
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
}
}
}

View File

@@ -6,6 +6,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
@@ -61,107 +62,136 @@ export class CreateLeagueWithSeasonAndScoringUseCase
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
this.validate(command);
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
try {
this.validate(command);
this.logger.info('Command validated successfully.');
const leagueId = uuidv4();
const leagueId = uuidv4();
this.logger.debug(`Generated leagueId: ${leagueId}`);
const league = League.create({
id: leagueId,
name: command.name,
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
// Presets are attached at scoring-config level; league settings use a stable points system id.
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
const league = League.create({
id: leagueId,
name: command.name,
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
await this.leagueRepository.create(league);
await this.leagueRepository.create(league);
this.logger.info(`League ${league.name} (${league.id}) created successfully.`);
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
order: 1,
status: 'active',
startDate: new Date(),
endDate: new Date(),
});
const seasonId = uuidv4();
this.logger.debug(`Generated seasonId: ${seasonId}`);
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
order: 1,
status: 'active',
startDate: new Date(),
endDate: new Date(),
});
await this.seasonRepository.create(season);
await this.seasonRepository.create(season);
this.logger.info(`Season ${season.name} (${season.id}) created for league ${league.id}.`);
const presetId = command.scoringPresetId ?? 'club-default';
const preset: LeagueScoringPresetDTO | undefined =
this.presetProvider.getPresetById(presetId);
const presetId = command.scoringPresetId ?? 'club-default';
this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`);
const preset: LeagueScoringPresetDTO | undefined =
this.presetProvider.getPresetById(presetId);
if (!preset) {
throw new Error(`Unknown scoring preset: ${presetId}`);
if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`);
throw new Error(`Unknown scoring preset: ${presetId}`);
}
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
const scoringConfig: LeagueScoringConfig = {
id: uuidv4(),
seasonId,
scoringPresetId: preset.id,
championships: [],
};
const fullConfigFactory = (await import(
'../../infrastructure/repositories/InMemoryScoringRepositories'
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
preset.id,
);
if (!presetFromInfra) {
this.logger.error(`Preset registry missing preset: ${preset.id}`);
throw new Error(`Preset registry missing preset: ${preset.id}`);
}
this.logger.debug(`Preset from infrastructure retrieved for ${preset.id}.`);
const infraConfig = presetFromInfra.createConfig({ seasonId });
const finalConfig: LeagueScoringConfig = {
...infraConfig,
scoringPresetId: preset.id,
};
await this.leagueScoringConfigRepository.save(finalConfig);
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
const result = {
leagueId: league.id,
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,
};
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return result;
} catch (error: any) {
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', {
command,
error: error.message,
stack: error.stack,
});
throw error;
}
const scoringConfig: LeagueScoringConfig = {
id: uuidv4(),
seasonId,
scoringPresetId: preset.id,
championships: [],
};
// For the initial alpha slice, we keep using the preset's config shape from the in-memory registry.
// The preset registry is responsible for building the full LeagueScoringConfig; we only attach the preset id here.
const fullConfigFactory = (await import(
'../../infrastructure/repositories/InMemoryScoringRepositories'
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
preset.id,
);
if (!presetFromInfra) {
throw new Error(`Preset registry missing preset: ${preset.id}`);
}
const infraConfig = presetFromInfra.createConfig({ seasonId });
const finalConfig: LeagueScoringConfig = {
...infraConfig,
scoringPresetId: preset.id,
};
await this.leagueScoringConfigRepository.save(finalConfig);
return {
leagueId: league.id,
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,
};
}
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: League name is required', { command });
throw new Error('League name is required');
}
if (!command.ownerId || command.ownerId.trim().length === 0) {
this.logger.warn('Validation failed: League ownerId is required', { command });
throw new Error('League ownerId is required');
}
if (!command.gameId || command.gameId.trim().length === 0) {
this.logger.warn('Validation failed: gameId is required', { command });
throw new Error('gameId is required');
}
if (!command.visibility) {
this.logger.warn('Validation failed: visibility is required', { command });
throw new Error('visibility is required');
}
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command });
throw new Error('maxDrivers must be greater than 0 when provided');
}
// Validate visibility-specific constraints
const visibility = LeagueVisibility.fromString(command.visibility);
if (visibility.isRanked()) {
// Ranked (public) leagues require minimum 10 drivers for competitive integrity
const driverCount = command.maxDrivers ?? 0;
if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) {
this.logger.warn(
`Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`,
{ command }
);
throw new Error(
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
`Current setting: ${driverCount}. ` +
@@ -169,5 +199,6 @@ export class CreateLeagueWithSeasonAndScoringUseCase
);
}
}
this.logger.debug('Validation successful.');
}
}

View File

@@ -1,5 +1,6 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
IAllRacesPagePresenter,
AllRacesPageResultDTO,
@@ -7,59 +8,68 @@ import type {
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { UseCase } => '@gridpilot/shared/application';
export class GetAllRacesPageDataUseCase
implements UseCase<void, AllRacesPageResultDTO, AllRacesPageViewModel, IAllRacesPagePresenter> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: ILogger,
) {}
async execute(_input: void, presenter: IAllRacesPagePresenter): Promise<void> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
this.logger.debug('Executing GetAllRacesPageDataUseCase');
try {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`);
const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name]));
const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name]));
const races: AllRacesListItemViewModel[] = allRaces
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.map((race) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField ?? null,
}));
const races: AllRacesListItemViewModel[] = allRaces
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.map((race) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField ?? null,
}));
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const league of allLeagues) {
uniqueLeagues.set(league.id, { id: league.id, name: league.name });
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const league of allLeagues) {
uniqueLeagues.set(league.id, { id: league.id, name: league.name });
}
const filters: AllRacesFilterOptionsViewModel = {
statuses: [
{ value: 'all', label: 'All Statuses' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'running', label: 'Live' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' },
],
leagues: Array.from(uniqueLeagues.values()),
};
const viewModel: AllRacesPageViewModel = {
races,
filters,
};
presenter.reset();
presenter.present(viewModel);
this.logger.debug('Successfully presented all races page data.');
} catch (error) {
this.logger.error('Error executing GetAllRacesPageDataUseCase', { error });
throw error;
}
const filters: AllRacesFilterOptionsViewModel = {
statuses: [
{ value: 'all', label: 'All Statuses' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'running', label: 'Live' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' },
],
leagues: Array.from(uniqueLeagues.values()),
};
const viewModel: AllRacesPageViewModel = {
races,
filters,
};
presenter.reset();
presenter.present(viewModel);
}
}

View File

@@ -6,6 +6,7 @@ import type {
} from '../presenters/IAllTeamsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { Team } from '../../domain/entities/Team';
import { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving all teams.
@@ -17,33 +18,44 @@ export class GetAllTeamsUseCase
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> {
this.logger.debug('Executing GetAllTeamsUseCase');
presenter.reset();
const teams = await this.teamRepository.findAll();
try {
const teams = await this.teamRepository.findAll();
if (teams.length === 0) {
this.logger.warn('No teams found.');
}
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
teams.map(async (team) => {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: [...team.leagues],
createdAt: team.createdAt,
memberCount,
};
}),
);
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
teams.map(async (team) => {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: [...team.leagues],
createdAt: team.createdAt,
memberCount,
};
}),
);
const dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
const dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
presenter.present(dto);
presenter.present(dto);
this.logger.info('Successfully retrieved all teams.');
} catch (error) {
this.logger.error('Error retrieving all teams:', error);
throw error; // Re-throw the error after logging
}
}
}

View File

@@ -6,6 +6,7 @@ import type {
DriverTeamViewModel,
} from '../presenters/IDriverTeamPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving a driver's team.
@@ -17,23 +18,29 @@ export class GetDriverTeamUseCase
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: IDriverTeamPresenter,
) {}
async execute(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise<void> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
presenter.reset();
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
this.logger.warn(`No active membership found for driverId: ${input.driverId}`);
return;
}
this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`);
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return;
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
const dto: DriverTeamResultDTO = {
team,
@@ -42,5 +49,6 @@ export class GetDriverTeamUseCase
};
presenter.present(dto);
this.logger.info(`Successfully presented driver team for driverId: ${input.driverId}`);
}
}

View File

@@ -12,6 +12,7 @@ import type { SponsorableEntityType } from '../../domain/entities/SponsorshipReq
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
@@ -46,74 +47,115 @@ export class GetEntitySponsorshipPricingUseCase
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly presenter: IEntitySponsorshipPricingPresenter,
private readonly logger: ILogger,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<void> {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.presenter.present(null);
return;
}
// Count pending requests by tier
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
this.logger.debug(
`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`,
{ dto },
);
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
// Count filled slots (for seasons, check SeasonSponsorship table)
let filledMainSlots = 0;
let filledSecondarySlots = 0;
try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (dto.entityType === 'season') {
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
const activeSponsorships = sponsorships.filter(s => s.isActive());
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
}
if (!pricing) {
this.logger.warn(
`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Presenting null.`,
{ dto },
);
this.presenter.present(null);
return;
}
const result: GetEntitySponsorshipPricingResultDTO = {
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
};
this.logger.debug(`Found pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, { pricing });
if (pricing.mainSlot) {
const mainMaxSlots = pricing.mainSlot.maxSlots;
result.mainSlot = {
tier: 'main',
price: pricing.mainSlot.price.amount,
currency: pricing.mainSlot.price.currency,
formattedPrice: pricing.mainSlot.price.format(),
benefits: pricing.mainSlot.benefits,
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
maxSlots: mainMaxSlots,
filledSlots: filledMainSlots,
pendingRequests: pendingMainCount,
// Count pending requests by tier
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId,
);
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
this.logger.debug(
`Pending requests counts: main=${pendingMainCount}, secondary=${pendingSecondaryCount}`,
);
// Count filled slots (for seasons, check SeasonSponsorship table)
let filledMainSlots = 0;
let filledSecondarySlots = 0;
if (dto.entityType === 'season') {
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
const activeSponsorships = sponsorships.filter(s => s.isActive());
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
this.logger.debug(
`Filled slots for season: main=${filledMainSlots}, secondary=${filledSecondarySlots}`,
);
}
const result: GetEntitySponsorshipPricingResultDTO = {
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
};
}
if (pricing.secondarySlots) {
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
result.secondarySlot = {
tier: 'secondary',
price: pricing.secondarySlots.price.amount,
currency: pricing.secondarySlots.price.currency,
formattedPrice: pricing.secondarySlots.price.format(),
benefits: pricing.secondarySlots.benefits,
available: pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
maxSlots: secondaryMaxSlots,
filledSlots: filledSecondarySlots,
pendingRequests: pendingSecondaryCount,
};
}
if (pricing.mainSlot) {
const mainMaxSlots = pricing.mainSlot.maxSlots;
result.mainSlot = {
tier: 'main',
price: pricing.mainSlot.price.amount,
currency: pricing.mainSlot.price.currency,
formattedPrice: pricing.mainSlot.price.format(),
benefits: pricing.mainSlot.benefits,
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
maxSlots: mainMaxSlots,
filledSlots: filledMainSlots,
pendingRequests: pendingMainCount,
};
this.logger.debug(`Main slot pricing information processed`, { mainSlot: result.mainSlot });
}
this.presenter.present(result);
if (pricing.secondarySlots) {
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
result.secondarySlot = {
tier: 'secondary',
price: pricing.secondarySlots.price.amount,
currency: pricing.secondarySlots.price.currency,
formattedPrice: pricing.secondarySlots.price.format(),
benefits: pricing.secondarySlots.benefits,
available:
pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
maxSlots: secondaryMaxSlots,
filledSlots: filledSecondarySlots,
pendingRequests: pendingSecondaryCount,
};
this.logger.debug(`Secondary slot pricing information processed`, {
secondarySlot: result.secondarySlot,
});
}
this.logger.info(
`Successfully retrieved and processed entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`,
{ result },
);
this.presenter.present(result);
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred';
if (error instanceof Error) {
errorMessage = error.message;
}
this.logger.error(
`Failed to get entity sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Error: ${errorMessage}`,
{ error, dto },
);
// Re-throw the error or present an error state if the presenter supports it
throw error;
}
}
}

View File

@@ -5,10 +5,11 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IResultRepository } from '../../domain/repositories/IRaceRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { ILogger } from '../../../shared/src/logging/ILogger';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
@@ -31,55 +32,78 @@ export class GetLeagueStatsUseCase
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
public readonly presenter: ILeagueStatsPresenter,
private readonly logger: ILogger,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetLeagueStatsUseCaseParams): Promise<void> {
this.logger.debug(
`Executing GetLeagueStatsUseCase with params: ${JSON.stringify(params)}`,
);
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League ${leagueId} not found`);
}
const races = await this.raceRepository.findByLeagueId(leagueId);
const completedRaces = races.filter(r => r.status === 'completed');
const scheduledRaces = races.filter(r => r.status === 'scheduled');
// Calculate SOF for each completed race
const sofValues: number[] = [];
for (const race of completedRaces) {
// Use stored SOF if available
if (race.strengthOfField) {
sofValues.push(race.strengthOfField);
continue;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.error(`League ${leagueId} not found`);
throw new Error(`League ${leagueId} not found`);
}
// Otherwise calculate from results
const results = await this.resultRepository.findByRaceId(race.id);
if (results.length === 0) continue;
const races = await this.raceRepository.findByLeagueId(leagueId);
const completedRaces = races.filter(r => r.status === 'completed');
const scheduledRaces = races.filter(r => r.status === 'scheduled');
this.logger.info(
`Found ${races.length} races for league ${leagueId}: ${completedRaces.length} completed, ${scheduledRaces.length} scheduled. `,
);
const driverIds = results.map(r => r.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const driverRatings = driverIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
// Calculate SOF for each completed race
const sofValues: number[] = [];
const sof = this.sofCalculator.calculate(driverRatings);
if (sof !== null) {
sofValues.push(sof);
for (const race of completedRaces) {
// Use stored SOF if available
if (race.strengthOfField) {
this.logger.debug(
`Using stored Strength of Field for race ${race.id}: ${race.strengthOfField}`,
);
sofValues.push(race.strengthOfField);
continue;
}
// Otherwise calculate from results
const results = await this.resultRepository.findByRaceId(race.id);
if (results.length === 0) {
this.logger.debug(`No results found for race ${race.id}. Skipping SOF calculation.`);
continue;
}
const driverIds = results.map(r => r.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const driverRatings = driverIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
const sof = this.sofCalculator.calculate(driverRatings);
if (sof !== null) {
this.logger.debug(`Calculated Strength of Field for race ${race.id}: ${sof}`);
sofValues.push(sof);
} else {
this.logger.warn(`Could not calculate Strength of Field for race ${race.id}`);
}
}
}
this.presenter.present(
leagueId,
races.length,
completedRaces.length,
scheduledRaces.length,
sofValues
);
this.presenter.present(
leagueId,
races.length,
completedRaces.length,
scheduledRaces.length,
sofValues,
);
this.logger.info(`Successfully presented league statistics for league ${leagueId}.`);
} catch (error) {
this.logger.error(`Error in GetLeagueStatsUseCase: ${error.message}`);
throw error;
}
}
}

View File

@@ -7,6 +7,7 @@ import type {
TeamJoinRequestsViewModel,
} from '../presenters/ITeamJoinRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving team join requests.
@@ -19,33 +20,44 @@ export class GetTeamJoinRequestsUseCase
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
private readonly logger: ILogger,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamJoinRequestsPresenter,
) {}
async execute(input: { teamId: string }, presenter: ITeamJoinRequestsPresenter): Promise<void> {
this.logger.debug('Executing GetTeamJoinRequestsUseCase', { teamId: input.teamId });
presenter.reset();
const requests = await this.membershipRepository.getJoinRequests(input.teamId);
try {
const requests = await this.membershipRepository.getJoinRequests(input.teamId);
this.logger.info('Successfully retrieved team join requests', { teamId: input.teamId, count: requests.length });
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
driverNames[request.driverId] = driver.name;
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
driverNames[request.driverId] = driver.name;
} else {
this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`);
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
this.logger.debug('Processed driver details for join request', { driverId: request.driverId });
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
};
presenter.present(dto);
} catch (error) {
this.logger.error('Error retrieving team join requests', { teamId: input.teamId, error });
throw error;
}
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -7,6 +7,7 @@ import type {
TeamMembersViewModel,
} from '../presenters/ITeamMembersPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
/**
* Use Case for retrieving team members.
@@ -19,33 +20,45 @@ export class GetTeamMembersUseCase
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
private readonly logger: ILogger,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamMembersPresenter,
) {}
async execute(input: { teamId: string }, presenter: ITeamMembersPresenter): Promise<void> {
this.logger.debug(`Executing GetTeamMembersUseCase for teamId: ${input.teamId}`);
presenter.reset();
const memberships = await this.membershipRepository.getTeamMembers(input.teamId);
try {
const memberships = await this.membershipRepository.getTeamMembers(input.teamId);
this.logger.info(`Found ${memberships.length} memberships for teamId: ${input.teamId}`);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const membership of memberships) {
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
driverNames[membership.driverId] = driver.name;
for (const membership of memberships) {
this.logger.debug(`Processing membership for driverId: ${membership.driverId}`);
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
driverNames[membership.driverId] = driver.name;
} else {
this.logger.warn(`Driver with ID ${membership.driverId} not found while fetching team members for team ${input.teamId}.`);
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
};
presenter.present(dto);
this.logger.info(`Successfully presented team members for teamId: ${input.teamId}`);
} catch (error) {
this.logger.error(`Error in GetTeamMembersUseCase for teamId: ${input.teamId}, error: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -13,6 +13,7 @@ import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '../presenters/IImportRaceResultsPresenter';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ImportRaceResultDTO {
id: string;
@@ -39,53 +40,72 @@ export class ImportRaceResultsUseCase
private readonly driverRepository: IDriverRepository,
private readonly standingRepository: IStandingRepository,
public readonly presenter: IImportRaceResultsPresenter,
private readonly logger: ILogger,
) {}
async execute(params: ImportRaceResultsParams): Promise<void> {
this.logger.debug('ImportRaceResultsUseCase:execute', { params });
const { raceId, results } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new EntityNotFoundError({ entity: 'race', id: raceId });
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`ImportRaceResultsUseCase: Race with ID ${raceId} not found.`);
throw new EntityNotFoundError({ entity: 'race', id: raceId });
}
this.logger.debug(`ImportRaceResultsUseCase: Race ${raceId} found.`);
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
this.logger.warn(`ImportRaceResultsUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`);
throw new EntityNotFoundError({ entity: 'league', id: race.leagueId });
}
this.logger.debug(`ImportRaceResultsUseCase: League ${league.id} found.`);
const existing = await this.resultRepository.existsByRaceId(raceId);
if (existing) {
this.logger.warn(`ImportRaceResultsUseCase: Results already exist for race ID: ${raceId}.`);
throw new BusinessRuleViolationError('Results already exist for this race');
}
this.logger.debug(`ImportRaceResultsUseCase: No existing results for race ${raceId}.`);
// Lookup drivers by iracingId and create results with driver.id
const entities = await Promise.all(
results.map(async (dto) => {
const driver = await this.driverRepository.findByIRacingId(dto.driverId);
if (!driver) {
this.logger.warn(`ImportRaceResultsUseCase: Driver with iRacing ID ${dto.driverId} not found for race ${raceId}.`);
throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`);
}
return Result.create({
id: dto.id,
raceId: dto.raceId,
driverId: driver.id,
position: dto.position,
fastestLap: dto.fastestLap,
incidents: dto.incidents,
startPosition: dto.startPosition,
});
}),
);
this.logger.debug('ImportRaceResultsUseCase:entities created', { count: entities.length });
await this.resultRepository.createMany(entities);
this.logger.info('ImportRaceResultsUseCase:race results created', { raceId });
await this.standingRepository.recalculate(league.id);
this.logger.info('ImportRaceResultsUseCase:standings recalculated', { leagueId: league.id });
const viewModel: ImportRaceResultsSummaryViewModel = {
importedCount: results.length,
standingsRecalculated: true,
};
this.logger.debug('ImportRaceResultsUseCase:presenting view model', { viewModel });
this.presenter.present(viewModel);
} catch (error) {
this.logger.error('ImportRaceResultsUseCase:execution error', { error });
throw error;
}
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
throw new EntityNotFoundError({ entity: 'league', id: race.leagueId });
}
const existing = await this.resultRepository.existsByRaceId(raceId);
if (existing) {
throw new BusinessRuleViolationError('Results already exist for this race');
}
// Lookup drivers by iracingId and create results with driver.id
const entities = await Promise.all(
results.map(async (dto) => {
const driver = await this.driverRepository.findByIRacingId(dto.driverId);
if (!driver) {
throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`);
}
return Result.create({
id: dto.id,
raceId: dto.raceId,
driverId: driver.id,
position: dto.position,
fastestLap: dto.fastestLap,
incidents: dto.incidents,
startPosition: dto.startPosition,
});
}),
);
await this.resultRepository.createMany(entities);
await this.standingRepository.recalculate(league.id);
const viewModel: ImportRaceResultsSummaryViewModel = {
importedCount: results.length,
standingsRecalculated: true,
};
this.presenter.present(viewModel);
}
}
}

View File

@@ -1,3 +1,4 @@
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
@@ -11,7 +12,10 @@ import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, LeagueMembership> {
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
constructor(
private readonly membershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
/**
* Joins a driver to a league as an active member.
@@ -21,20 +25,31 @@ export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, Lea
* - Creates a new active membership with role "member" and current timestamp.
*/
async execute(command: JoinLeagueCommandDTO): Promise<LeagueMembership> {
this.logger.debug('Attempting to join league', { command });
const { leagueId, driverId } = command;
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
if (existing) {
throw new BusinessRuleViolationError('Already a member or have a pending request');
try {
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
if (existing) {
this.logger.warn('Driver already a member or has pending request', { leagueId, driverId });
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const membership = LeagueMembership.create({
leagueId,
driverId,
role: 'member' as MembershipRole,
status: 'active' as MembershipStatus,
});
const savedMembership = await this.membershipRepository.saveMembership(membership);
this.logger.info('Successfully joined league', { membershipId: savedMembership.id });
return savedMembership;
} catch (error) {
if (!(error instanceof BusinessRuleViolationError)) {
this.logger.error('Failed to join league due to an unexpected error', { command, error: error.message });
}
throw error;
}
const membership = LeagueMembership.create({
leagueId,
driverId,
role: 'member' as MembershipRole,
status: 'active' as MembershipStatus,
});
return this.membershipRepository.saveMembership(membership);
}
}

View File

@@ -7,6 +7,7 @@ import type {
} from '../../domain/types/TeamMembership';
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
import {
BusinessRuleViolationError,
EntityNotFoundError,
@@ -16,36 +17,50 @@ export class JoinTeamUseCase implements AsyncUseCase<JoinTeamCommandDTO, void> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: JoinTeamCommandDTO): Promise<void> {
this.logger.debug('Attempting to join team', { command });
const { teamId, driverId } = command;
const existingActive = await this.membershipRepository.getActiveMembershipForDriver(
driverId,
);
if (existingActive) {
throw new BusinessRuleViolationError('Driver already belongs to a team');
try {
const existingActive = await this.membershipRepository.getActiveMembershipForDriver(
driverId,
);
if (existingActive) {
this.logger.warn('Driver already belongs to a team', { driverId, teamId });
throw new BusinessRuleViolationError('Driver already belongs to a team');
}
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
if (existingMembership) {
this.logger.warn('Driver already has a pending or active membership request', { driverId, teamId });
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const team = await this.teamRepository.findById(teamId);
if (!team) {
this.logger.error('Team not found', { entity: 'team', id: teamId });
throw new EntityNotFoundError({ entity: 'team', id: teamId });
}
const membership: TeamMembership = {
teamId,
driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
this.logger.info('Driver successfully joined team', { driverId, teamId });
} catch (error) {
if (error instanceof BusinessRuleViolationError || error instanceof EntityNotFoundError) {
throw error;
}
this.logger.error('Failed to join team due to an unexpected error', { error, command });
throw error;
}
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
if (existingMembership) {
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new EntityNotFoundError({ entity: 'team', id: teamId });
}
const membership: TeamMembership = {
teamId,
driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
}
}

View File

@@ -11,6 +11,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface QuickPenaltyCommand {
raceId: string;
@@ -27,50 +28,60 @@ export class QuickPenaltyUseCase
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
throw new Error('Race not found');
this.logger.debug('Executing QuickPenaltyUseCase', { command });
try {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
this.logger.warn('Race not found', { raceId: command.raceId });
throw new Error('Race not found');
}
// Validate admin has authority
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const adminMembership = memberships.find(
m => m.driverId === command.adminId && m.status === 'active'
);
if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) {
this.logger.warn('Unauthorized admin attempting to issue penalty', { adminId: command.adminId, leagueId: race.leagueId });
throw new Error('Only league owners and admins can issue penalties');
}
// Map infraction + severity to penalty type and value
const { type, value, reason } = this.mapInfractionToPenalty(
command.infractionType,
command.severity
);
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type,
...(value !== undefined ? { value } : {}),
reason,
issuedBy: command.adminId,
status: 'applied', // Quick penalties are applied immediately
issuedAt: new Date(),
appliedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
this.logger.info('Quick penalty applied successfully', { penaltyId: penalty.id, raceId: command.raceId, driverId: command.driverId });
return { penaltyId: penalty.id };
} catch (error) {
this.logger.error('Failed to apply quick penalty', { command, error: error.message });
throw error;
}
// Validate admin has authority
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const adminMembership = memberships.find(
m => m.driverId === command.adminId && m.status === 'active'
);
if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) {
throw new Error('Only league owners and admins can issue penalties');
}
// Map infraction + severity to penalty type and value
const { type, value, reason } = this.mapInfractionToPenalty(
command.infractionType,
command.severity
);
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type,
...(value !== undefined ? { value } : {}),
reason,
issuedBy: command.adminId,
status: 'applied', // Quick penalties are applied immediately
issuedAt: new Date(),
appliedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
return { penaltyId: penalty.id };
}
private mapInfractionToPenalty(
@@ -132,6 +143,7 @@ export class QuickPenaltyUseCase
};
default:
this.logger.error(`Unknown infraction type: ${infractionType}`);
throw new Error(`Unknown infraction type: ${infractionType}`);
}
}

View File

@@ -3,6 +3,7 @@ import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repos
import { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import type { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
import {
BusinessRuleViolationError,
PermissionDeniedError,
@@ -14,8 +15,9 @@ export class RegisterForRaceUseCase
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
/**
* Mirrors legacy registerForRace behavior:
* - throws if already registered
@@ -24,22 +26,26 @@ export class RegisterForRaceUseCase
*/
async execute(command: RegisterForRaceCommandDTO): Promise<void> {
const { raceId, leagueId, driverId } = command;
this.logger.debug('RegisterForRaceUseCase: executing command', { raceId, leagueId, driverId });
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
if (alreadyRegistered) {
this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`);
throw new BusinessRuleViolationError('Already registered for this race');
}
const membership = await this.membershipRepository.getMembership(leagueId, driverId);
if (!membership || membership.status !== 'active') {
this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`);
throw new PermissionDeniedError('NOT_ACTIVE_MEMBER', 'Must be an active league member to register for races');
}
const registration = RaceRegistration.create({
raceId,
driverId,
});
await this.registrationRepository.register(registration);
this.logger.info(`RegisterForRaceUseCase: driver ${driverId} successfully registered for race ${raceId}`);
}
}