wip
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}`,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}.`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user