rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View File

@@ -0,0 +1,104 @@
/**
* Use Case: AcceptSponsorshipRequestUseCase
*
* Allows an entity owner to accept a sponsorship request.
* 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';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface AcceptSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person accepting
}
export interface AcceptSponsorshipRequestResultDTO {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly logger: ILogger,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
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');
}
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;
}
}
}

View File

@@ -0,0 +1,124 @@
/**
* Use Case: ApplyForSponsorshipUseCase
*
* Allows a sponsor to apply for a sponsorship slot on any entity
* (driver, team, race, or season/league).
*/
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
EntityNotFoundError,
BusinessRuleViolationError,
} from '../errors/RacingApplicationError';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ApplyForSponsorshipDTO {
sponsorId: string;
entityType: SponsorableEntityType;
entityId: string;
tier: SponsorshipTier;
offeredAmount: number; // in cents
currency?: Currency;
message?: string;
}
export interface ApplyForSponsorshipResultDTO {
requestId: string;
status: 'pending';
createdAt: Date;
}
export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO>
{
constructor(
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',
);
}
// 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`,
);
}
// Check if sponsor already has a pending request for this entity
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
dto.sponsorId,
dto.entityType,
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',
);
}
// 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()}`,
);
}
// Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD');
const request = SponsorshipRequest.create({
id: requestId,
sponsorId: dto.sponsorId,
entityType: dto.entityType,
entityId: dto.entityId,
tier: dto.tier,
offeredAmount,
...(dto.message !== undefined ? { message: dto.message } : {}),
});
await this.sponsorshipRequestRepo.create(request);
return {
requestId: request.id,
status: 'pending',
createdAt: request.createdAt,
};
}
}

View File

@@ -0,0 +1,104 @@
/**
* Application Use Case: ApplyPenaltyUseCase
*
* Allows a steward to apply a penalty to a driver for an incident during a race.
* The penalty can be standalone or linked to an upheld protest.
*/
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
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;
driverId: string;
stewardId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
notes?: string;
}
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> {
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');
}
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');
}
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;
}
}
}

View File

@@ -0,0 +1,61 @@
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
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,
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) {
this.logger.warn(`Team join request with ID ${requestId} not found`);
throw new Error('Join request not found');
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
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

@@ -0,0 +1,44 @@
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
*
* Encapsulates the workflow for cancelling a race:
* - loads the race by id
* - throws if the race does not exist
* - delegates cancellation rules to the Race domain entity
* - persists the updated race via the repository.
*/
export interface CancelRaceCommandDTO {
raceId: string;
}
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}`);
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;
}
}
}

View File

@@ -0,0 +1,86 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@gridpilot/shared/domain';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
/**
* Use Case: CloseRaceEventStewardingUseCase
*
* Scheduled job that checks for race events with expired stewarding windows
* and closes them, triggering final results notifications.
*
* This would typically be run by a scheduled job (e.g., every 5 minutes)
* to automatically close stewarding windows based on league configuration.
*/
export interface CloseRaceEventStewardingCommand {
// No parameters needed - finds all expired events automatically
}
export class CloseRaceEventStewardingUseCase
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
{
constructor(
private readonly raceEventRepository: IRaceEventRepository,
private readonly domainEventPublisher: IDomainEventPublisher,
) {}
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
for (const raceEvent of expiredEvents) {
await this.closeStewardingForRaceEvent(raceEvent);
}
}
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> {
try {
// Close the stewarding window
const closedRaceEvent = raceEvent.closeStewarding();
await this.raceEventRepository.update(closedRaceEvent);
// Get list of participating drivers (would need to be implemented)
const driverIds = await this.getParticipatingDriverIds(raceEvent);
// Check if any penalties were applied during stewarding
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
// Publish domain event to trigger final results notifications
const event = new RaceEventStewardingClosedEvent({
raceEventId: raceEvent.id,
leagueId: raceEvent.leagueId,
seasonId: raceEvent.seasonId,
closedAt: new Date(),
driverIds,
hadPenaltiesApplied,
});
await this.domainEventPublisher.publish(event);
} catch (error) {
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
// In production, this would trigger alerts/monitoring
}
}
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> {
// In a real implementation, this would query race registrations
// For the prototype, we'll return a mock list
// This would typically involve:
// 1. Get all sessions in the race event
// 2. For each session, get registered drivers
// 3. Return unique driver IDs across all sessions
// Mock implementation for prototype
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
}
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
// In a real implementation, this would check if any penalties were issued
// during the stewarding window for this race event
// This would query the penalty repository for penalties related to this race event
// Mock implementation for prototype - randomly simulate penalties
return Math.random() > 0.7; // 30% chance of penalties being applied
}
}

View File

@@ -0,0 +1,188 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
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
*
* Encapsulates the workflow for completing a race:
* - loads the race by id
* - throws if the race does not exist
* - delegates completion rules to the Race domain entity
* - automatically generates realistic results for registered drivers
* - updates league standings
* - persists all changes via repositories.
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
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;
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;
}
}
private generateRaceResults(
raceId: string,
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,
rating: driverRatings.get(driverId) ?? 1500, // Default rating
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
}));
// Sort by performance (rating + randomization)
driverPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
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 => ({
...p,
randomFactor: Math.random() - 0.5, // New randomization for quali
}));
qualiPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 150);
const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA;
});
this.logger.debug(`Qualifying performances generated for race ${raceId}.`);
// Generate results
const results: Result[] = [];
for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i];
const position = i + 1;
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
// Generate realistic lap times (90-120 seconds for a lap)
const baseLapTime = 90000 + Math.random() * 30000;
const positionBonus = (position - 1) * 500; // Winners are faster
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
// Generate incidents (0-3, higher for lower positions)
const incidentProbability = Math.min(0.8, position / driverPerformances.length);
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
results.push(
Result.create({
id: `${raceId}-${driverId}`,
raceId,
driverId,
position,
startPosition,
fastestLap,
incidents,
})
);
}
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) {
const existing = resultsByDriver.get(result.driverId) || [];
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) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
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)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
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

@@ -0,0 +1,132 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
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
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
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}`);
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;
}
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
});
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
await this.standingRepository.save(standing);
}
}
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
const driverResults = results.map(result => ({
driverId: result.driverId,
position: result.position,
totalDrivers,
incidents: result.incidents,
startPosition: result.startPosition,
}));
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
}
}

View File

@@ -0,0 +1,204 @@
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { Season } from '../../domain/entities/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
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,
} from '../ports/LeagueScoringPresetProvider';
import {
LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS,
} from '../../domain/value-objects/LeagueVisibility';
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
export type LeagueVisibilityInput = 'ranked' | 'unranked' | 'public' | 'private';
export interface CreateLeagueWithSeasonAndScoringCommand {
name: string;
description?: string;
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityInput;
ownerId: string;
gameId: string;
maxDrivers?: number;
maxTeams?: number;
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
export interface CreateLeagueWithSeasonAndScoringResultDTO {
leagueId: string;
seasonId: string;
scoringPresetId?: string;
scoringPresetName?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
try {
this.validate(command);
this.logger.info('Command validated successfully.');
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: {
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
await this.leagueRepository.create(league);
this.logger.info(`League ${league.name} (${league.id}) created successfully.`);
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);
this.logger.info(`Season ${season.name} (${season.id}) created for league ${league.id}.`);
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) {
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;
}
}
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');
}
const visibility = LeagueVisibility.fromString(command.visibility);
if (visibility.isRanked()) {
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}. ` +
`For smaller groups, consider creating an Unranked (Friends) league instead.`
);
}
}
this.logger.debug('Validation successful.');
}
}

View File

@@ -0,0 +1,53 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { Team } from '../../domain/entities/Team';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/types/TeamMembership';
import type {
CreateTeamCommandDTO,
CreateTeamResultDTO,
} from '../dto/CreateTeamCommandDTO';
export class CreateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> {
const { name, tag, description, ownerId, leagues } = command;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId,
);
if (existingMembership) {
throw new Error('Driver already belongs to a team');
}
const team = Team.create({
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
});
const createdTeam = await this.teamRepository.create(team);
const membership: TeamMembership = {
teamId: createdTeam.id,
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
return { team: createdTeam };
}
}

View File

@@ -0,0 +1,68 @@
/**
* Application Use Case: FileProtestUseCase
*
* Allows a driver to file a protest against another driver for an incident during a race.
*/
import { Protest, type ProtestIncident } from '../../domain/entities/Protest';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
export interface FileProtestCommand {
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
}
export class FileProtestUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: FileProtestCommand): Promise<{ protestId: string }> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
throw new Error('Race not found');
}
// Validate drivers are not the same
if (command.protestingDriverId === command.accusedDriverId) {
throw new Error('Cannot file a protest against yourself');
}
// Validate protesting driver is a member of the league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const protestingDriverMembership = memberships.find(
m => m.driverId === command.protestingDriverId && m.status === 'active'
);
if (!protestingDriverMembership) {
throw new Error('Protesting driver is not an active member of this league');
}
// Create the protest
const protest = Protest.create({
id: randomUUID(),
raceId: command.raceId,
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});
await this.protestRepository.create(protest);
return { protestId: protest.id };
}
}

View File

@@ -0,0 +1,94 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type {
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter,
LeagueEnrichedData,
} from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityAndScoringUseCase
implements
UseCase<
void,
LeagueEnrichedData[],
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter
>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityAndScoringPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedDriverSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const seasons = await this.seasonRepository.findByLeagueId(league.id);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig: LeagueEnrichedData['scoringConfig'];
let game: LeagueEnrichedData['game'];
let preset: LeagueEnrichedData['preset'];
if (activeSeason) {
const scoringConfigResult =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
scoringConfig = scoringConfigResult ?? undefined;
if (scoringConfig) {
const gameResult = await this.gameRepository.findById(activeSeason.gameId);
game = gameResult ?? undefined;
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
}
}
enrichedLeagues.push({
league,
usedDriverSlots,
...(activeSeason ? { season: activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
...(preset ? { preset } : {}),
});
}
presenter.present(enrichedLeagues);
}
}

View File

@@ -0,0 +1,54 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type {
IAllLeaguesWithCapacityPresenter,
AllLeaguesWithCapacityResultDTO,
AllLeaguesWithCapacityViewModel,
} from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityUseCase
implements UseCase<void, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel, IAllLeaguesWithCapacityPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(
_input: void,
presenter: IAllLeaguesWithCapacityPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>();
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
memberCounts.set(league.id, usedSlots);
}
const dto: AllLeaguesWithCapacityResultDTO = {
leagues,
memberCounts,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,75 @@
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,
AllRacesPageViewModel,
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
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> {
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 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 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;
}
}
}

View File

@@ -0,0 +1,61 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
} 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.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
{
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();
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 dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
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

@@ -0,0 +1,308 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
DashboardDriverSummaryViewModel,
DashboardRaceSummaryViewModel,
DashboardRecentResultViewModel,
DashboardLeagueStandingSummaryViewModel,
DashboardFeedItemSummaryViewModel,
DashboardFeedSummaryViewModel,
DashboardFriendSummaryViewModel,
} from '../presenters/IDashboardOverviewPresenter';
interface DashboardDriverStatsAdapter {
rating: number | null;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
consistency: number | null;
}
export interface GetDashboardOverviewParams {
driverId: string;
}
export class GetDashboardOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly standingRepository: IStandingRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly feedRepository: IFeedRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {}
async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise<void> {
const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
this.driverRepository.findById(driverId),
this.leagueRepository.findAll(),
this.raceRepository.findAll(),
this.resultRepository.findAll(),
this.feedRepository.getFeedForDriver(driverId),
this.socialRepository.getFriends(driverId),
]);
const leagueMap = new Map(allLeagues.map(league => [league.id, league.name]));
const driverStats = this.getDriverStats(driverId);
const currentDriver: DashboardDriverSummaryViewModel | null = driver
? {
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
rating: driverStats?.rating ?? null,
globalRank: driverStats?.overallRank ?? null,
totalRaces: driverStats?.totalRaces ?? 0,
wins: driverStats?.wins ?? 0,
podiums: driverStats?.podiums ?? 0,
consistency: driverStats?.consistency ?? null,
}
: null;
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
const driverLeagueIds = new Set(driverLeagues.map(league => league.id));
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
driverLeagueIds.has(race.leagueId),
);
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap);
const nextRace: DashboardRaceSummaryViewModel | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const upcomingRacesSummaries: DashboardRaceSummaryViewModel[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
].slice().sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
const recentResults = this.buildRecentResults(allResults, allRaces, allLeagues, driverId);
const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries(
driverLeagues,
driverId,
);
const activeLeaguesCount = this.computeActiveLeaguesCount(
upcomingRacesSummaries,
leagueStandingsSummaries,
);
const feedSummary = this.buildFeedSummary(feedItems);
const friendsSummary = this.buildFriendsSummary(friends);
const viewModel: DashboardOverviewViewModel = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces: upcomingRacesSummaries,
activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends: friendsSummary,
};
presenter.reset();
presenter.present(viewModel);
}
private async getDriverLeagues(allLeagues: any[], driverId: string): Promise<any[]> {
const driverLeagues: any[] = [];
for (const league of allLeagues) {
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
if (membership && membership.status === 'active') {
driverLeagues.push(league);
}
}
return driverLeagues;
}
private async partitionUpcomingRacesByRegistration(
upcomingRaces: any[],
driverId: string,
leagueMap: Map<string, string>,
): Promise<{
myUpcomingRaces: DashboardRaceSummaryViewModel[];
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
}> {
const myUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
const otherUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
for (const race of upcomingRaces) {
const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId);
const summary = this.mapRaceToSummary(race, leagueMap, true);
if (isRegistered) {
myUpcomingRaces.push(summary);
} else {
otherUpcomingRaces.push(summary);
}
}
return { myUpcomingRaces, otherUpcomingRaces };
}
private mapRaceToSummary(
race: any,
leagueMap: Map<string, string>,
isMyLeague: boolean,
): DashboardRaceSummaryViewModel {
return {
id: race.id,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
isMyLeague,
};
}
private buildRecentResults(
allResults: any[],
allRaces: any[],
allLeagues: any[],
driverId: string,
): DashboardRecentResultViewModel[] {
const raceById = new Map(allRaces.map(race => [race.id, race]));
const leagueById = new Map(allLeagues.map(league => [league.id, league]));
const driverResults = allResults.filter(result => result.driverId === driverId);
const enriched = driverResults
.map(result => {
const race = raceById.get(result.raceId);
if (!race) return null;
const league = leagueById.get(race.leagueId);
const finishedAt = race.scheduledAt.toISOString();
const item: DashboardRecentResultViewModel = {
raceId: race.id,
raceName: race.track,
leagueId: race.leagueId,
leagueName: league?.name ?? 'Unknown League',
finishedAt,
position: result.position,
incidents: result.incidents,
};
return item;
})
.filter((item): item is DashboardRecentResultViewModel => !!item)
.sort(
(a, b) =>
new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(),
);
const RECENT_RESULTS_LIMIT = 5;
return enriched.slice(0, RECENT_RESULTS_LIMIT);
}
private async buildLeagueStandingsSummaries(
driverLeagues: any[],
driverId: string,
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id);
const driverStanding = standings.find(
(standing: any) => standing.driverId === driverId,
);
summaries.push({
leagueId: league.id,
leagueName: league.name,
position: driverStanding?.position ?? 0,
points: driverStanding?.points ?? 0,
totalDrivers: standings.length,
});
}
return summaries;
}
private computeActiveLeaguesCount(
upcomingRaces: DashboardRaceSummaryViewModel[],
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[],
): number {
const activeLeagueIds = new Set<string>();
for (const race of upcomingRaces) {
activeLeagueIds.add(race.leagueId);
}
for (const standing of leagueStandingsSummaries) {
activeLeagueIds.add(standing.leagueId);
}
return activeLeagueIds.size;
}
private buildFeedSummary(feedItems: any[]): DashboardFeedSummaryViewModel {
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
}));
return {
notificationCount: items.length,
items,
};
}
private buildFriendsSummary(friends: any[]): DashboardFriendSummaryViewModel[] {
return friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
}));
}
}

View File

@@ -0,0 +1,54 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
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.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter>
{
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,
membership,
driverId: input.driverId,
};
presenter.present(dto);
this.logger.info(`Successfully presented driver team for driverId: ${input.driverId}`);
}
}

View File

@@ -0,0 +1,52 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
IDriversLeaderboardPresenter,
DriversLeaderboardResultDTO,
DriversLeaderboardViewModel,
} from '../presenters/IDriversLeaderboardPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriversLeaderboardUseCase
implements UseCase<void, DriversLeaderboardResultDTO, DriversLeaderboardViewModel, IDriversLeaderboardPresenter>
{
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageServicePort,
) {}
async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise<void> {
presenter.reset();
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const stats: DriversLeaderboardResultDTO['stats'] = {};
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
for (const driver of drivers) {
const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
stats[driver.id] = driverStats;
}
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
}
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,161 @@
/**
* Application Use Case: GetEntitySponsorshipPricingUseCase
*
* Retrieves sponsorship pricing configuration for any entity.
* Used by sponsors to see available slots and prices.
*/
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
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;
entityId: string;
}
export interface SponsorshipSlotDTO {
tier: SponsorshipTier;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
}
export interface GetEntitySponsorshipPricingResultDTO {
entityType: SponsorableEntityType;
entityId: string;
acceptingApplications: boolean;
customRequirements?: string;
mainSlot?: SponsorshipSlotDTO;
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingUseCase
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, void> {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly presenter: IEntitySponsorshipPricingPresenter,
private readonly logger: ILogger,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<void> {
this.logger.debug(
`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`,
{ dto },
);
try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.logger.warn(
`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}. Presenting null.`,
{ dto },
);
this.presenter.present(null);
return;
}
this.logger.debug(`Found pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`, { pricing });
// 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.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 });
}
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

@@ -0,0 +1,109 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type {
ILeagueDriverSeasonStatsPresenter,
LeagueDriverSeasonStatsResultDTO,
LeagueDriverSeasonStatsViewModel,
} from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
}
export interface GetLeagueDriverSeasonStatsUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueDriverSeasonStatsUseCase
implements
UseCase<
GetLeagueDriverSeasonStatsUseCaseParams,
LeagueDriverSeasonStatsResultDTO,
LeagueDriverSeasonStatsViewModel,
ILeagueDriverSeasonStatsPresenter
>
{
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly driverRatingPort: DriverRatingPort,
) {}
async execute(
params: GetLeagueDriverSeasonStatsUseCaseParams,
presenter: ILeagueDriverSeasonStatsPresenter,
): Promise<void> {
presenter.reset();
const { leagueId } = params;
// Get standings and races for the league
const [standings, races] = await Promise.all([
this.standingRepository.findByLeagueId(leagueId),
this.raceRepository.findByLeagueId(leagueId),
]);
// Fetch all penalties for all races in the league
const penaltiesArrays = await Promise.all(
races.map(race => this.penaltyRepository.findByRaceId(race.id))
);
const penaltiesForLeague = penaltiesArrays.flat();
// Group penalties by driver for quick lookup
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
for (const p of penaltiesForLeague) {
// Only count applied penalties
if (p.status !== 'applied') continue;
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
// Convert penalty to points delta based on type
if (p.type === 'points_deduction' && p.value) {
// Points deductions are negative
current.baseDelta -= p.value;
}
penaltiesByDriver.set(p.driverId, current);
}
// Collect driver ratings
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
driverRatings.set(standing.driverId, ratingInfo);
}
// Collect driver results
const driverResults = new Map<string, Array<{ position: number }>>();
for (const standing of standings) {
const results = await this.resultRepository.findByDriverIdAndLeagueId(
standing.driverId,
leagueId,
);
driverResults.set(standing.driverId, results);
}
const dto: LeagueDriverSeasonStatsResultDTO = {
leagueId,
standings: standings.map(standing => ({
driverId: standing.driverId,
position: standing.position,
points: standing.points,
racesCompleted: standing.racesCompleted,
})),
penalties: penaltiesByDriver,
driverResults,
driverRatings,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,60 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
) {}
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new EntityNotFoundError({ entity: 'league', id: leagueId });
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig = await (async () => {
if (!activeSeason) return undefined;
return this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
})();
let game = await (async () => {
if (!activeSeason || !activeSeason.gameId) return undefined;
return this.gameRepository.findById(activeSeason.gameId);
})();
const data: LeagueFullConfigData = {
league,
...(activeSeason ? { activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
};
presenter.reset();
presenter.present(data);
}
}

View File

@@ -0,0 +1,75 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type {
ILeagueScoringConfigPresenter,
LeagueScoringConfigData,
LeagueScoringConfigViewModel,
} from '../presenters/ILeagueScoringConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving a league's scoring configuration for its active season.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueScoringConfigUseCase
implements UseCase<{ leagueId: string }, LeagueScoringConfigData, LeagueScoringConfigViewModel, ILeagueScoringConfigPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(params: { leagueId: string }, presenter: ILeagueScoringConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League ${leagueId} not found`);
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
throw new Error(`No seasons found for league ${leagueId}`);
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
throw new Error(`No active season could be determined for league ${leagueId}`);
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
throw new Error(`No scoring config found for season ${activeSeason.id}`);
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
throw new Error(`Game ${activeSeason.gameId} not found`);
}
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const data: LeagueScoringConfigData = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
...(presetId !== undefined ? { scoringPresetId: presetId } : {}),
...(preset !== undefined ? { preset } : {}),
championships: scoringConfig.championships,
};
presenter.reset();
presenter.present(data);
}
}

View File

@@ -0,0 +1,34 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type {
ILeagueStandingsPresenter,
LeagueStandingsResultDTO,
LeagueStandingsViewModel,
} from '../presenters/ILeagueStandingsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league standings.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase
implements
UseCase<GetLeagueStandingsUseCaseParams, LeagueStandingsResultDTO, LeagueStandingsViewModel, ILeagueStandingsPresenter>
{
constructor(private readonly standingRepository: IStandingRepository) {}
async execute(
params: GetLeagueStandingsUseCaseParams,
presenter: ILeagueStandingsPresenter,
): Promise<void> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
const dto: LeagueStandingsResultDTO = {
standings,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -0,0 +1,109 @@
/**
* Use Case for retrieving league statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
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,
} from '../../domain/services/StrengthOfFieldCalculator';
export interface GetLeagueStatsUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league statistics including average SOF across completed races.
*/
export class GetLeagueStatsUseCase
implements AsyncUseCase<GetLeagueStatsUseCaseParams, void> {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly raceRepository: IRaceRepository,
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;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.error(`League ${leagueId} not found`);
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');
this.logger.info(
`Found ${races.length} races for league ${leagueId}: ${completedRaces.length} completed, ${scheduledRaces.length} scheduled. `,
);
// Calculate SOF for each completed race
const sofValues: number[] = [];
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.logger.info(`Successfully presented league statistics for league ${leagueId}.`);
} catch (error) {
this.logger.error(`Error in GetLeagueStatsUseCase: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,97 @@
/**
* Application Use Case: GetPendingSponsorshipRequestsUseCase
*
* Retrieves pending sponsorship requests for an entity owner to review.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '../presenters/IPendingSponsorshipRequestsPresenter';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface PendingSponsorshipRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: SponsorshipTier;
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
}
export interface GetPendingSponsorshipRequestsResultDTO {
entityType: SponsorableEntityType;
entityId: string;
requests: PendingSponsorshipRequestDTO[];
totalCount: number;
}
export class GetPendingSponsorshipRequestsUseCase
implements UseCase<
GetPendingSponsorshipRequestsDTO,
GetPendingSponsorshipRequestsResultDTO,
PendingSponsorshipRequestsViewModel,
IPendingSponsorshipRequestsPresenter
> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
) {}
async execute(
dto: GetPendingSponsorshipRequestsDTO,
presenter: IPendingSponsorshipRequestsPresenter,
): Promise<void> {
presenter.reset();
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
for (const request of requests) {
const sponsor = await this.sponsorRepo.findById(request.sponsorId);
requestDTOs.push({
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}),
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
...(request.message !== undefined ? { message: request.message } : {}),
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
});
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
});
}
}

View File

@@ -0,0 +1,451 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type {
IProfileOverviewPresenter,
ProfileOverviewViewModel,
ProfileOverviewDriverSummaryViewModel,
ProfileOverviewStatsViewModel,
ProfileOverviewFinishDistributionViewModel,
ProfileOverviewTeamMembershipViewModel,
ProfileOverviewSocialSummaryViewModel,
ProfileOverviewExtendedProfileViewModel,
} from '../presenters/IProfileOverviewPresenter';
interface ProfileDriverStatsAdapter {
rating: number | null;
wins: number;
podiums: number;
dnfs: number;
totalRaces: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
overallRank: number | null;
consistency: number | null;
percentile: number | null;
}
interface DriverRankingEntry {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface GetProfileOverviewParams {
driverId: string;
}
export class GetProfileOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<ProfileOverviewViewModel | null> {
const { driverId } = params;
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
const emptyViewModel: ProfileOverviewViewModel = {
currentDriver: null,
stats: null,
finishDistribution: null,
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
extendedProfile: null,
};
this.presenter.present(emptyViewModel);
return emptyViewModel;
}
const [statsAdapter, teams, friends] = await Promise.all([
Promise.resolve(this.getDriverStats(driverId)),
this.teamRepository.findAll(),
this.socialRepository.getFriends(driverId),
]);
const driverSummary = this.buildDriverSummary(driver, statsAdapter);
const stats = this.buildStats(statsAdapter);
const finishDistribution = this.buildFinishDistribution(statsAdapter);
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
const socialSummary = this.buildSocialSummary(friends);
const extendedProfile = this.buildExtendedProfile(driver.id);
const viewModel: ProfileOverviewViewModel = {
currentDriver: driverSummary,
stats,
finishDistribution,
teamMemberships,
socialSummary,
extendedProfile,
};
this.presenter.present(viewModel);
return viewModel;
}
private buildDriverSummary(
driver: any,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId ?? null,
joinedAt:
driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
rating: stats?.rating ?? null,
globalRank: stats?.overallRank ?? fallbackRank,
consistency: stats?.consistency ?? null,
bio: driver.bio ?? null,
totalDrivers,
};
}
private computeFallbackRank(
driverId: string,
rankings: DriverRankingEntry[],
): number | null {
const index = rankings.findIndex(entry => entry.driverId === driverId);
if (index === -1) {
return null;
}
return index + 1;
}
private buildStats(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewStatsViewModel | null {
if (!stats) {
return null;
}
const totalRaces = stats.totalRaces;
const dnfs = stats.dnfs;
const finishedRaces = Math.max(totalRaces - dnfs, 0);
const finishRate =
totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null;
const winRate =
totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null;
const podiumRate =
totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null;
return {
totalRaces,
wins: stats.wins,
podiums: stats.podiums,
dnfs,
avgFinish: stats.avgFinish,
bestFinish: stats.bestFinish,
worstFinish: stats.worstFinish,
finishRate,
winRate,
podiumRate,
percentile: stats.percentile,
rating: stats.rating,
consistency: stats.consistency,
overallRank: stats.overallRank,
};
}
private buildFinishDistribution(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewFinishDistributionViewModel | null {
if (!stats || stats.totalRaces <= 0) {
return null;
}
const totalRaces = stats.totalRaces;
const dnfs = stats.dnfs;
const finishedRaces = Math.max(totalRaces - dnfs, 0);
const estimatedTopTen = Math.min(
finishedRaces,
Math.round(totalRaces * 0.7),
);
const topTen = Math.max(estimatedTopTen, stats.podiums);
const other = Math.max(totalRaces - topTen, 0);
return {
totalRaces,
wins: stats.wins,
podiums: stats.podiums,
topTen,
dnfs,
other,
};
}
private async buildTeamMemberships(
driverId: string,
teams: any[],
): Promise<ProfileOverviewTeamMembershipViewModel[]> {
const memberships: ProfileOverviewTeamMembershipViewModel[] = [];
for (const team of teams) {
const membership = await this.teamMembershipRepository.getMembership(
team.id,
driverId,
);
if (!membership) continue;
memberships.push({
teamId: team.id,
teamName: team.name,
teamTag: team.tag ?? null,
role: membership.role,
joinedAt:
membership.joinedAt instanceof Date
? membership.joinedAt.toISOString()
: new Date(membership.joinedAt).toISOString(),
isCurrent: membership.status === 'active',
});
}
memberships.sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
return memberships;
}
private buildSocialSummary(friends: any[]): ProfileOverviewSocialSummaryViewModel {
return {
friendsCount: friends.length,
friends: friends.map(friend => ({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
})),
};
}
private buildExtendedProfile(driverId: string): ProfileOverviewExtendedProfileViewModel {
const hash = driverId
.split('')
.reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
const socialOptions: Array<
Array<{
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
handle: string;
url: string;
}>
> = [
[
{
platform: 'twitter',
handle: '@speedracer',
url: 'https://twitter.com/speedracer',
},
{
platform: 'youtube',
handle: 'SpeedRacer Racing',
url: 'https://youtube.com/@speedracer',
},
{
platform: 'twitch',
handle: 'speedracer_live',
url: 'https://twitch.tv/speedracer_live',
},
],
[
{
platform: 'twitter',
handle: '@racingpro',
url: 'https://twitter.com/racingpro',
},
{
platform: 'discord',
handle: 'RacingPro#1234',
url: '#',
},
],
[
{
platform: 'twitch',
handle: 'simracer_elite',
url: 'https://twitch.tv/simracer_elite',
},
{
platform: 'youtube',
handle: 'SimRacer Elite',
url: 'https://youtube.com/@simracerelite',
},
],
];
const achievementSets: Array<
Array<{
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: 'common' | 'rare' | 'epic' | 'legendary';
earnedAt: Date;
}>
> = [
[
{
id: '1',
title: 'First Victory',
description: 'Win your first race',
icon: 'trophy',
rarity: 'common',
earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Clean Racer',
description: '10 races without incidents',
icon: 'star',
rarity: 'rare',
earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Podium Streak',
description: '5 consecutive podium finishes',
icon: 'medal',
rarity: 'epic',
earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
{
id: '4',
title: 'Championship Glory',
description: 'Win a league championship',
icon: 'crown',
rarity: 'legendary',
earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Rookie No More',
description: 'Complete 25 races',
icon: 'target',
rarity: 'common',
earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Consistent Performer',
description: 'Maintain 80%+ consistency rating',
icon: 'zap',
rarity: 'rare',
earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Endurance Master',
description: 'Complete a 24-hour race',
icon: 'star',
rarity: 'epic',
earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Welcome Racer',
description: 'Join GridPilot',
icon: 'star',
rarity: 'common',
earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Team Player',
description: 'Join a racing team',
icon: 'medal',
rarity: 'rare',
earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000),
},
],
];
const tracks = [
'Spa-Francorchamps',
'Nürburgring Nordschleife',
'Suzuka',
'Monza',
'Interlagos',
'Silverstone',
];
const cars = [
'Porsche 911 GT3 R',
'Ferrari 488 GT3',
'Mercedes-AMG GT3',
'BMW M4 GT3',
'Audi R8 LMS',
];
const styles = [
'Aggressive Overtaker',
'Consistent Pacer',
'Strategic Calculator',
'Late Braker',
'Smooth Operator',
];
const timezones = [
'EST (UTC-5)',
'CET (UTC+1)',
'PST (UTC-8)',
'GMT (UTC+0)',
'JST (UTC+9)',
];
const hours = [
'Evenings (18:00-23:00)',
'Weekends only',
'Late nights (22:00-02:00)',
'Flexible schedule',
];
const socialHandles =
socialOptions[hash % socialOptions.length] ?? [];
const achievementsSource =
achievementSets[hash % achievementSets.length] ?? [];
return {
socialHandles,
achievements: achievementsSource.map(achievement => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: achievement.earnedAt.toISOString(),
})),
racingStyle: styles[hash % styles.length] ?? 'Consistent Pacer',
favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track',
favoriteCar: cars[hash % cars.length] ?? 'Unknown Car',
timezone: timezones[hash % timezones.length] ?? 'UTC',
availableHours: hours[hash % hours.length] ?? 'Flexible schedule',
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};
}
}

View File

@@ -0,0 +1,167 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
RaceDetailRaceViewModel,
RaceDetailLeagueViewModel,
RaceDetailEntryViewModel,
RaceDetailUserResultViewModel,
} from '../presenters/IRaceDetailPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case: GetRaceDetailUseCase
*
* Given a race id and current driver id:
* - When the race exists, it builds a view model with race, league, entry list, registration flags and user result.
* - When the race does not exist, it presents a view model with an error and no race data.
*
* Given a completed race with a result for the driver:
* - When computing rating change, it applies the same position-based formula used in the legacy UI.
*/
export interface GetRaceDetailQueryParams {
raceId: string;
driverId: string;
}
export class GetRaceDetailUseCase
implements UseCase<GetRaceDetailQueryParams, RaceDetailViewModel, RaceDetailViewModel, IRaceDetailPresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly driverRepository: IDriverRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly imageService: IImageServicePort,
) {}
async execute(params: GetRaceDetailQueryParams, presenter: IRaceDetailPresenter): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
const emptyViewModel: RaceDetailViewModel = {
race: null,
league: null,
entryList: [],
registration: {
isUserRegistered: false,
canRegister: false,
},
userResult: null,
error: 'Race not found',
};
presenter.present(emptyViewModel);
return;
}
const [league, registeredDriverIds, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.getRegisteredDrivers(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
]);
const ratings = this.driverRatingProvider.getRatings(registeredDriverIds);
const drivers = await Promise.all(
registeredDriverIds.map(id => this.driverRepository.findById(id)),
);
const entryList: RaceDetailEntryViewModel[] = drivers
.filter((d): d is NonNullable<typeof d> => d !== null)
.map(driver => ({
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
rating: ratings.get(driver.id) ?? null,
isCurrentUser: driver.id === driverId,
}));
const isUserRegistered = registeredDriverIds.includes(driverId);
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister = !!membership && membership.status === 'active' && isUpcoming;
let userResultView: RaceDetailUserResultViewModel | null = null;
if (race.status === 'completed') {
const results = await this.resultRepository.findByRaceId(race.id);
const userResult = results.find(r => r.driverId === driverId) ?? null;
if (userResult) {
const ratingChange = this.calculateRatingChange(userResult.position);
userResultView = {
position: userResult.position,
startPosition: userResult.startPosition,
incidents: userResult.incidents,
fastestLap: userResult.fastestLap,
positionChange: userResult.getPositionChange(),
isPodium: userResult.isPodium(),
isClean: userResult.isClean(),
ratingChange,
};
}
}
const raceView: RaceDetailRaceViewModel = {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField ?? null,
...(race.registeredCount !== undefined ? { registeredCount: race.registeredCount } : {}),
...(race.maxParticipants !== undefined ? { maxParticipants: race.maxParticipants } : {}),
};
const leagueView: RaceDetailLeagueViewModel | null = league
? {
id: league.id,
name: league.name,
description: league.description,
settings: {
...(league.settings.maxDrivers !== undefined
? { maxDrivers: league.settings.maxDrivers }
: {}),
...(league.settings.qualifyingFormat !== undefined
? { qualifyingFormat: league.settings.qualifyingFormat }
: {}),
},
}
: null;
const viewModel: RaceDetailViewModel = {
race: raceView,
league: leagueView,
entryList,
registration: {
isUserRegistered,
canRegister,
},
userResult: userResultView,
};
presenter.present(viewModel);
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Use Case: GetRacePenaltiesUseCase
*
* Returns all penalties applied for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type {
IRacePenaltiesPresenter,
RacePenaltiesResultDTO,
RacePenaltiesViewModel,
} from '../presenters/IRacePenaltiesPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRacePenaltiesInput {
raceId: string;
}
export class GetRacePenaltiesUseCase
implements
UseCase<GetRacePenaltiesInput, RacePenaltiesResultDTO, RacePenaltiesViewModel, IRacePenaltiesPresenter>
{
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRacePenaltiesInput, presenter: IRacePenaltiesPresenter): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
penalties.forEach((penalty) => {
driverIds.add(penalty.driverId);
driverIds.add(penalty.issuedBy);
});
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
presenter.reset();
const dto: RacePenaltiesResultDTO = {
penalties,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,60 @@
/**
* Use Case: GetRaceProtestsUseCase
*
* Returns all protests filed for a specific race, with driver details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type {
IRaceProtestsPresenter,
RaceProtestsResultDTO,
RaceProtestsViewModel,
} from '../presenters/IRaceProtestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase
implements
UseCase<GetRaceProtestsInput, RaceProtestsResultDTO, RaceProtestsViewModel, IRaceProtestsPresenter>
{
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(input: GetRaceProtestsInput, presenter: IRaceProtestsPresenter): Promise<void> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
driverIds.add(protest.reviewedBy);
}
});
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
presenter.reset();
const dto: RaceProtestsResultDTO = {
protests,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,38 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type {
IRaceRegistrationsPresenter,
RaceRegistrationsResultDTO,
RaceRegistrationsViewModel,
} from '../presenters/IRaceRegistrationsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case: GetRaceRegistrationsUseCase
*
* Returns registered driver IDs for a race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetRaceRegistrationsUseCase
implements UseCase<GetRaceRegistrationsQueryParamsDTO, RaceRegistrationsResultDTO, RaceRegistrationsViewModel, IRaceRegistrationsPresenter>
{
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(
params: GetRaceRegistrationsQueryParamsDTO,
presenter: IRaceRegistrationsPresenter,
): Promise<void> {
presenter.reset();
const { raceId } = params;
const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId);
const dto: RaceRegistrationsResultDTO = {
registeredDriverIds,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,161 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type {
IRaceResultsDetailPresenter,
RaceResultsDetailViewModel,
RaceResultsPenaltySummaryViewModel,
} from '../presenters/IRaceResultsDetailPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { League } from '../../domain/entities/League';
import type { Result } from '../../domain/entities/Result';
import type { Driver } from '../../domain/entities/Driver';
import type { Penalty } from '../../domain/entities/Penalty';
export interface GetRaceResultsDetailParams {
raceId: string;
driverId?: string;
}
function buildPointsSystem(league: League | null): Record<number, number> | undefined {
if (!league) return undefined;
const pointsSystems: Record<string, Record<number, number>> = {
'f1-2024': {
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
},
indycar: {
1: 50,
2: 40,
3: 35,
4: 32,
5: 30,
6: 28,
7: 26,
8: 24,
9: 22,
10: 20,
11: 19,
12: 18,
13: 17,
14: 16,
15: 15,
},
};
const customPoints = league.settings.customPoints;
if (customPoints) {
return customPoints;
}
const preset = pointsSystems[league.settings.pointsSystem];
if (preset) {
return preset;
}
return pointsSystems['f1-2024'];
}
function getFastestLapTime(results: Result[]): number | undefined {
if (results.length === 0) return undefined;
return Math.min(...results.map((r) => r.fastestLap));
}
function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] {
return penalties.map((p) => ({
driverId: p.driverId,
type: p.type,
...(p.value !== undefined ? { value: p.value } : {}),
}));
}
export class GetRaceResultsDetailUseCase
implements
UseCase<
GetRaceResultsDetailParams,
RaceResultsDetailViewModel,
RaceResultsDetailViewModel,
IRaceResultsDetailPresenter
>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly penaltyRepository: IPenaltyRepository,
) {}
async execute(
params: GetRaceResultsDetailParams,
presenter: IRaceResultsDetailPresenter,
): Promise<void> {
presenter.reset();
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
const errorViewModel: RaceResultsDetailViewModel = {
race: null,
league: null,
results: [],
drivers: [],
penalties: [],
...(driverId ? { currentDriverId: driverId } : {}),
error: 'Race not found',
};
presenter.present(errorViewModel);
return;
}
const [league, results, drivers, penalties] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.resultRepository.findByRaceId(raceId),
this.driverRepository.findAll(),
this.penaltyRepository.findByRaceId(raceId),
]);
const effectiveCurrentDriverId =
driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined);
const pointsSystem = buildPointsSystem(league as League | null);
const fastestLapTime = getFastestLapTime(results);
const penaltySummary = mapPenaltySummary(penalties);
const viewModel: RaceResultsDetailViewModel = {
race: {
id: race.id,
leagueId: race.leagueId,
track: race.track,
scheduledAt: race.scheduledAt,
status: race.status,
},
league: league
? {
id: league.id,
name: league.name,
}
: null,
results,
drivers,
penalties: penaltySummary,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
presenter.present(viewModel);
}
}

View File

@@ -0,0 +1,93 @@
/**
* Use Case: GetRaceWithSOFUseCase
*
* Returns race details enriched with calculated Strength of Field (SOF).
* SOF is calculated from participant ratings if not already stored on the race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { IRaceWithSOFPresenter, RaceWithSOFResultDTO } from '../presenters/IRaceWithSOFPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export class GetRaceWithSOFUseCase
implements UseCase<GetRaceWithSOFQueryParams, RaceWithSOFResultDTO, import('../presenters/IRaceWithSOFPresenter').RaceWithSOFViewModel, IRaceWithSOFPresenter>
{
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams, presenter: IRaceWithSOFPresenter): Promise<void> {
presenter.reset();
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return;
}
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId);
} else {
// For upcoming/running races, use registrations
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
}
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) {
const ratings = this.driverRatingProvider.getRatings(participantIds);
const driverRatings = participantIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
presenter.reset();
const dto: RaceWithSOFResultDTO = {
raceId: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt,
track: race.track ?? '',
trackId: race.trackId ?? '',
car: race.car ?? '',
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
participantCount: participantIds.length,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,50 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type {
IRacesPagePresenter,
RacesPageResultDTO,
RacesPageViewModel,
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRacesPageDataUseCase
implements UseCase<void, RacesPageResultDTO, RacesPageViewModel, IRacesPagePresenter>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
) {}
async execute(_input: void, presenter: IRacesPagePresenter): Promise<void> {
presenter.reset();
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
const races = allRaces
.sort((a, b) => a.scheduledAt.getTime() - b.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,
isUpcoming: race.isUpcoming(),
isLive: race.isLive(),
isPast: race.isPast(),
}));
const dto: RacesPageResultDTO = {
races,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,179 @@
/**
* Application Use Case: GetSponsorDashboardUseCase
*
* Returns sponsor dashboard metrics including sponsorships, impressions, and investment data.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type {
ISponsorDashboardPresenter,
SponsorDashboardViewModel,
} from '../presenters/ISponsorDashboardPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorDashboardQueryParams {
sponsorId: string;
}
export interface SponsoredLeagueDTO {
id: string;
name: string;
tier: 'main' | 'secondary';
drivers: number;
races: number;
impressions: number;
status: 'active' | 'upcoming' | 'completed';
}
export interface SponsorDashboardDTO {
sponsorId: string;
sponsorName: string;
metrics: {
impressions: number;
impressionsChange: number;
uniqueViewers: number;
viewersChange: number;
races: number;
drivers: number;
exposure: number;
exposureChange: number;
};
sponsoredLeagues: SponsoredLeagueDTO[];
investment: {
activeSponsorships: number;
totalInvestment: number;
costPerThousandViews: number;
};
}
export class GetSponsorDashboardUseCase
implements UseCase<GetSponsorDashboardQueryParams, SponsorDashboardDTO | null, SponsorDashboardViewModel, ISponsorDashboardPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(
params: GetSponsorDashboardQueryParams,
presenter: ISponsorDashboardPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
// Aggregate data across all sponsorships
let totalImpressions = 0;
let totalDrivers = 0;
let totalRaces = 0;
let totalInvestment = 0;
const sponsoredLeagues: SponsoredLeagueDTO[] = [];
const seenLeagues = new Set<string>();
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
// Only process each league once
if (seenLeagues.has(season.leagueId)) continue;
seenLeagues.add(season.leagueId);
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
totalDrivers += driverCount;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const raceCount = races.length;
totalRaces += raceCount;
// Calculate impressions based on completed races and drivers
// This is a simplified calculation - in production would come from analytics
const completedRaces = races.filter(r => r.status === 'completed').length;
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
totalImpressions += leagueImpressions;
// Determine status based on season dates
const now = new Date();
let status: 'active' | 'upcoming' | 'completed' = 'active';
if (season.endDate && season.endDate < now) {
status = 'completed';
} else if (season.startDate && season.startDate > now) {
status = 'upcoming';
}
// Add investment
totalInvestment += sponsorship.pricing.amount;
sponsoredLeagues.push({
id: league.id,
name: league.name,
tier: sponsorship.tier,
drivers: driverCount,
races: raceCount,
impressions: leagueImpressions,
status,
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const costPerThousandViews = totalImpressions > 0
? (totalInvestment / (totalImpressions / 1000))
: 0;
// Calculate unique viewers (simplified: assume 70% of impressions are unique)
const uniqueViewers = Math.round(totalImpressions * 0.7);
// Calculate exposure score (0-100 based on tier distribution)
const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length;
const exposure = sponsorships.length > 0
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
const dto: SponsorDashboardDTO = {
sponsorId,
sponsorName: sponsor.name,
metrics: {
impressions: totalImpressions,
impressionsChange: 12.5, // Would come from analytics comparison
uniqueViewers,
viewersChange: 8.3, // Would come from analytics comparison
races: totalRaces,
drivers: totalDrivers,
exposure,
exposureChange: 5.2, // Would come from analytics comparison
},
sponsoredLeagues,
investment: {
activeSponsorships,
totalInvestment,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,178 @@
/**
* Application Use Case: GetSponsorSponsorshipsUseCase
*
* Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship';
import type {
ISponsorSponsorshipsPresenter,
SponsorSponsorshipsViewModel,
} from '../presenters/ISponsorSponsorshipsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetSponsorSponsorshipsQueryParams {
sponsorId: string;
}
export interface SponsorshipDetailDTO {
id: string;
leagueId: string;
leagueName: string;
seasonId: string;
seasonName: string;
seasonStartDate?: Date;
seasonEndDate?: Date;
tier: SponsorshipTier;
status: SponsorshipStatus;
pricing: {
amount: number;
currency: string;
};
platformFee: {
amount: number;
currency: string;
};
netAmount: {
amount: number;
currency: string;
};
metrics: {
drivers: number;
races: number;
completedRaces: number;
impressions: number;
};
createdAt: Date;
activatedAt?: Date;
}
export interface SponsorSponsorshipsDTO {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailDTO[];
summary: {
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
totalPlatformFees: number;
currency: string;
};
}
export class GetSponsorSponsorshipsUseCase
implements UseCase<GetSponsorSponsorshipsQueryParams, SponsorSponsorshipsDTO | null, SponsorSponsorshipsViewModel, ISponsorSponsorshipsPresenter>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(
params: GetSponsorSponsorshipsQueryParams,
presenter: ISponsorSponsorshipsPresenter,
): Promise<void> {
presenter.reset();
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
presenter.present(null);
return;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
const sponsorshipDetails: SponsorshipDetailDTO[] = [];
let totalInvestment = 0;
let totalPlatformFees = 0;
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const completedRaces = races.filter(r => r.status === 'completed').length;
// Calculate impressions
const impressions = completedRaces * driverCount * 100;
// Calculate platform fee (10%)
const platformFee = sponsorship.getPlatformFee();
const netAmount = sponsorship.getNetAmount();
totalInvestment += sponsorship.pricing.amount;
totalPlatformFees += platformFee.amount;
sponsorshipDetails.push({
id: sponsorship.id,
leagueId: league.id,
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
amount: sponsorship.pricing.amount,
currency: sponsorship.pricing.currency,
},
platformFee: {
amount: platformFee.amount,
currency: platformFee.currency,
},
netAmount: {
amount: netAmount.amount,
currency: netAmount.currency,
},
metrics: {
drivers: driverCount,
races: races.length,
completedRaces,
impressions,
},
createdAt: sponsorship.createdAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const dto: SponsorSponsorshipsDTO = {
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
summary: {
totalSponsorships: sponsorships.length,
activeSponsorships,
totalInvestment,
totalPlatformFees,
currency: 'USD',
},
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,44 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
ITeamDetailsPresenter,
TeamDetailsResultDTO,
TeamDetailsViewModel,
} from '../presenters/ITeamDetailsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for retrieving team details.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamDetailsUseCase
implements UseCase<{ teamId: string; driverId: string }, TeamDetailsResultDTO, TeamDetailsViewModel, ITeamDetailsPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(
params: { teamId: string; driverId: string },
presenter: ITeamDetailsPresenter,
): Promise<void> {
presenter.reset();
const { teamId, driverId } = params;
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership = await this.membershipRepository.getMembership(teamId, driverId);
const dto: TeamDetailsResultDTO = {
team,
membership,
driverId,
};
presenter.present(dto);
}
}

View File

@@ -0,0 +1,63 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
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.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamJoinRequestsUseCase
implements UseCase<{ teamId: string }, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, ITeamJoinRequestsPresenter>
{
constructor(
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();
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> = {};
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 });
}
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;
}
}
}

View File

@@ -0,0 +1,64 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
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.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamMembersUseCase
implements UseCase<{ teamId: string }, TeamMembersResultDTO, TeamMembersViewModel, ITeamMembersPresenter>
{
constructor(
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();
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> = {};
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);
}
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;
}
}
}

View File

@@ -0,0 +1,88 @@
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type {
ITeamsLeaderboardPresenter,
TeamsLeaderboardResultDTO,
TeamsLeaderboardViewModel,
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
interface DriverStatsAdapter {
rating: number | null;
wins: number;
totalRaces: number;
}
/**
* Use case: GetTeamsLeaderboardUseCase
*
* Plain constructor-injected dependencies (no decorators) to keep the
* application layer framework-agnostic and compatible with test tooling.
*/
export class GetTeamsLeaderboardUseCase
implements UseCase<void, TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel, ITeamsLeaderboardPresenter> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
) {}
async execute(_input: void, presenter: ITeamsLeaderboardPresenter): Promise<void> {
const allTeams = await this.teamRepository.findAll();
const teams: any[] = [];
await Promise.all(
allTeams.map(async (team) => {
const memberships = await this.teamMembershipRepository.getTeamMembers(team.id);
const memberCount = memberships.length;
let ratingSum = 0;
let ratingCount = 0;
let totalWins = 0;
let totalRaces = 0;
for (const membership of memberships) {
const stats = this.getDriverStats(membership.driverId);
if (!stats) continue;
if (typeof stats.rating === 'number') {
ratingSum += stats.rating;
ratingCount += 1;
}
totalWins += stats.wins ?? 0;
totalRaces += stats.totalRaces ?? 0;
}
const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null;
const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating);
teams.push({
id: team.id,
name: team.name,
memberCount,
rating: averageRating,
totalWins,
totalRaces,
performanceLevel,
isRecruiting: true,
createdAt: new Date(),
description: team.description,
});
})
);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
const result: TeamsLeaderboardResultDTO = {
teams,
recruitingCount,
};
presenter.reset();
presenter.present(result);
}
}

View File

@@ -0,0 +1,111 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
BusinessRuleViolationError,
EntityNotFoundError,
} from '../errors/RacingApplicationError';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '../presenters/IImportRaceResultsPresenter';
import type { ILogger } from '../../../shared/src/logging/ILogger';
export interface ImportRaceResultDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export interface ImportRaceResultsParams {
raceId: string;
results: ImportRaceResultDTO[];
}
export class ImportRaceResultsUseCase
implements AsyncUseCase<ImportRaceResultsParams, void>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
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;
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;
}
}
}

View File

@@ -0,0 +1,22 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRegistrationStatusPresenter';
/**
* Use Case: IsDriverRegisteredForRaceUseCase
*
* Checks if a driver is registered for a specific race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class IsDriverRegisteredForRaceUseCase {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
public readonly presenter: IDriverRegistrationStatusPresenter,
) {}
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<void> {
const { raceId, driverId } = params;
const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
this.presenter.present(isRegistered, raceId, driverId);
}
}

View File

@@ -0,0 +1,55 @@
import type { ILogger } from '../../../shared/src/logging/ILogger';
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
LeagueMembership,
type MembershipRole,
type MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, LeagueMembership> {
constructor(
private readonly membershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
/**
* Joins a driver to a league as an active member.
*
* Mirrors the behavior of the legacy joinLeague function:
* - Throws when membership already exists for this league/driver.
* - 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;
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;
}
}
}

View File

@@ -0,0 +1,66 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
} 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,
} from '../errors/RacingApplicationError';
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;
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;
}
}
}

View File

@@ -0,0 +1,25 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { LeaveTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class LeaveTeamUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: LeaveTeamCommandDTO): Promise<void> {
const { teamId, driverId } = command;
const membership = await this.membershipRepository.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Not a member of this team');
}
if (membership.role === 'owner') {
throw new Error(
'Team owner cannot leave. Transfer ownership or disband team first.',
);
}
await this.membershipRepository.removeMembership(teamId, driverId);
}
}

View File

@@ -0,0 +1,28 @@
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type {
ILeagueScoringPresetsPresenter,
LeagueScoringPresetsResultDTO,
LeagueScoringPresetsViewModel,
} from '../presenters/ILeagueScoringPresetsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for listing league scoring presets.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class ListLeagueScoringPresetsUseCase
implements UseCase<void, LeagueScoringPresetsResultDTO, LeagueScoringPresetsViewModel, ILeagueScoringPresetsPresenter>
{
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
async execute(_input: void, presenter: ILeagueScoringPresetsPresenter): Promise<void> {
const presets = await this.presetProvider.listPresets();
const dto: LeagueScoringPresetsResultDTO = {
presets,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -0,0 +1,99 @@
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
import type { ILeagueSchedulePreviewPresenter } from '../presenters/ILeagueSchedulePreviewPresenter';
interface PreviewLeagueScheduleQueryParams {
schedule: LeagueScheduleDTO;
maxRounds?: number;
}
export class PreviewLeagueScheduleUseCase {
constructor(
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
private readonly presenter: ILeagueSchedulePreviewPresenter,
) {}
execute(params: PreviewLeagueScheduleQueryParams): void {
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
if (!seasonSchedule) {
throw new Error('Invalid schedule data');
}
const maxRounds =
params.maxRounds && params.maxRounds > 0
? Math.min(params.maxRounds, seasonSchedule.plannedRounds)
: seasonSchedule.plannedRounds;
const slots = this.scheduleGenerator.generateSlotsUpTo(seasonSchedule, maxRounds);
const rounds = slots.map((slot) => ({
roundNumber: slot.roundNumber,
scheduledAt: slot.scheduledAt.toISOString(),
timezoneId: slot.timezone.getId(),
}));
const summary = this.buildSummary(params.schedule, rounds);
this.presenter.present({
rounds,
summary,
});
}
private buildSummary(
schedule: LeagueScheduleDTO,
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>,
): string {
if (rounds.length === 0) {
return 'No rounds scheduled.';
}
const firstRound = rounds[0]!;
const lastRound = rounds[rounds.length - 1]!;
const first = new Date(firstRound.scheduledAt);
const last = new Date(lastRound.scheduledAt);
const firstDate = first.toISOString().slice(0, 10);
const lastDate = last.toISOString().slice(0, 10);
const timePart = schedule.raceStartTime;
const tz = schedule.timezoneId;
let recurrenceDescription: string;
if (schedule.recurrenceStrategy === 'weekly') {
const days = (schedule.weekdays ?? []).join(', ');
recurrenceDescription = `Every ${days}`;
} else if (schedule.recurrenceStrategy === 'everyNWeeks') {
const interval = schedule.intervalWeeks ?? 1;
const days = (schedule.weekdays ?? []).join(', ');
recurrenceDescription = `Every ${interval} week(s) on ${days}`;
} else if (schedule.recurrenceStrategy === 'monthlyNthWeekday') {
const ordinalLabel = this.ordinalToLabel(schedule.monthlyOrdinal ?? 1);
const weekday = schedule.monthlyWeekday ?? 'Mon';
recurrenceDescription = `Every ${ordinalLabel} ${weekday}`;
} else {
recurrenceDescription = 'Custom recurrence';
}
return `${recurrenceDescription} at ${timePart} ${tz}, starting ${firstDate}${rounds.length} rounds from ${firstDate} to ${lastDate}.`;
}
private ordinalToLabel(ordinal: 1 | 2 | 3 | 4): string {
switch (ordinal) {
case 1:
return '1st';
case 2:
return '2nd';
case 3:
return '3rd';
case 4:
return '4th';
default:
return `${ordinal}th`;
}
}
}

View File

@@ -0,0 +1,150 @@
/**
* Use Case: QuickPenaltyUseCase
*
* Allows league admins to quickly issue common penalties without protest process.
* Designed for fast, common penalty scenarios like track limits, warnings, etc.
*/
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
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;
driverId: string;
adminId: string;
infractionType: 'track_limits' | 'unsafe_rejoin' | 'aggressive_driving' | 'false_start' | 'other';
severity: 'warning' | 'minor' | 'major' | 'severe';
notes?: string;
}
export class QuickPenaltyUseCase
implements AsyncUseCase<QuickPenaltyCommand, { penaltyId: string }> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> {
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;
}
}
private mapInfractionToPenalty(
infractionType: QuickPenaltyCommand['infractionType'],
severity: QuickPenaltyCommand['severity']
): { type: PenaltyType; value?: number; reason: string } {
const severityMultipliers = {
warning: 1,
minor: 2,
major: 3,
severe: 4,
};
const multiplier = severityMultipliers[severity];
switch (infractionType) {
case 'track_limits':
if (severity === 'warning') {
return { type: 'warning', reason: 'Track limits violation - warning' };
}
return {
type: 'points_deduction',
value: multiplier,
reason: `Track limits violation - ${multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
case 'unsafe_rejoin':
return {
type: 'time_penalty',
value: 5 * multiplier,
reason: `Unsafe rejoining to track - +${5 * multiplier}s time penalty`
};
case 'aggressive_driving':
if (severity === 'warning') {
return { type: 'warning', reason: 'Aggressive driving - warning' };
}
return {
type: 'points_deduction',
value: 2 * multiplier,
reason: `Aggressive driving - ${2 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
case 'false_start':
return {
type: 'grid_penalty',
value: multiplier,
reason: `False start - ${multiplier} grid position${multiplier > 1 ? 's' : ''} penalty`
};
case 'other':
if (severity === 'warning') {
return { type: 'warning', reason: 'General infraction - warning' };
}
return {
type: 'points_deduction',
value: 3 * multiplier,
reason: `General infraction - ${3 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
default:
this.logger.error(`Unknown infraction type: ${infractionType}`);
throw new Error(`Unknown infraction type: ${infractionType}`);
}
}
}

View File

@@ -0,0 +1,131 @@
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig';
import type { SessionType } from '@gridpilot/racing/domain/types/SessionType';
import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator';
import type {
ChampionshipStandingsDTO,
ChampionshipStandingsRowDTO,
} from '../dto/ChampionshipStandingsDTO';
export class RecalculateChampionshipStandingsUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly championshipStandingRepository: IChampionshipStandingRepository,
private readonly eventScoringService: EventScoringService,
private readonly championshipAggregator: ChampionshipAggregator,
) {}
async execute(params: {
seasonId: string;
championshipId: string;
}): Promise<ChampionshipStandingsDTO> {
const { seasonId, championshipId } = params;
const season = await this.seasonRepository.findById(seasonId);
if (!season) {
throw new Error(`Season not found: ${seasonId}`);
}
const leagueScoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(seasonId);
if (!leagueScoringConfig) {
throw new Error(`League scoring config not found for season: ${seasonId}`);
}
const championship = this.findChampionshipConfig(
leagueScoringConfig.championships,
championshipId,
);
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const eventPointsByEventId: Record<string, ReturnType<EventScoringService['scoreSession']>> =
{};
for (const race of races) {
// Map existing Race.sessionType into scoring SessionType where possible.
const sessionType = this.mapRaceSessionType(race.sessionType);
if (!championship.sessionTypes.includes(sessionType)) {
continue;
}
const results = await this.resultRepository.findByRaceId(race.id);
// Fetch penalties for this specific race
const penalties = await this.penaltyRepository.findByRaceId(race.id);
const participantPoints = this.eventScoringService.scoreSession({
seasonId,
championship,
sessionType,
results,
penalties,
});
eventPointsByEventId[race.id] = participantPoints;
}
const standings: ChampionshipStanding[] = this.championshipAggregator.aggregate({
seasonId,
championship,
eventPointsByEventId,
});
await this.championshipStandingRepository.saveAll(standings);
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
participant: s.participant,
position: s.position,
totalPoints: s.totalPoints,
resultsCounted: s.resultsCounted,
resultsDropped: s.resultsDropped,
}));
const dto: ChampionshipStandingsDTO = {
seasonId,
championshipId: championship.id,
championshipName: championship.name,
rows,
};
return dto;
}
private findChampionshipConfig(
configs: ChampionshipConfig[],
championshipId: string,
): ChampionshipConfig {
const found = configs.find((c) => c.id === championshipId);
if (!found) {
throw new Error(`Championship config not found: ${championshipId}`);
}
return found;
}
private mapRaceSessionType(sessionType: string): SessionType {
if (sessionType === 'race') {
return 'main';
}
if (
sessionType === 'practice' ||
sessionType === 'qualifying' ||
sessionType === 'timeTrial'
) {
return sessionType;
}
return 'main';
}
}

View File

@@ -0,0 +1,51 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
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,
} from '../errors/RacingApplicationError';
export class RegisterForRaceUseCase
implements AsyncUseCase<RegisterForRaceCommandDTO, void>
{
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
private readonly logger: ILogger,
) {}
/**
* Mirrors legacy registerForRace behavior:
* - throws if already registered
* - validates active league membership
* - registers driver for race
*/
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}`);
}
}

View File

@@ -0,0 +1,53 @@
/**
* Use Case: RejectSponsorshipRequestUseCase
*
* Allows an entity owner to reject a sponsorship request.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
export interface RejectSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person rejecting
reason?: string;
}
export interface RejectSponsorshipRequestResultDTO {
requestId: string;
status: 'rejected';
rejectedAt: Date;
reason?: string;
}
export class RejectSponsorshipRequestUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
) {}
async execute(dto: RejectSponsorshipRequestDTO): Promise<RejectSponsorshipRequestResultDTO> {
// 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 reject a ${request.status} sponsorship request`);
}
// Reject the request
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
await this.sponsorshipRequestRepo.update(rejectedRequest);
// TODO: In a real implementation, notify the sponsor
return {
requestId: rejectedRequest.id,
status: 'rejected',
rejectedAt: rejectedRequest.respondedAt!,
...(rejectedRequest.rejectionReason !== undefined
? { reason: rejectedRequest.rejectionReason }
: {}),
};
}
}

View File

@@ -0,0 +1,13 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { RejectTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class RejectTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: RejectTeamJoinRequestCommandDTO): Promise<void> {
const { requestId } = command;
await this.membershipRepository.removeJoinRequest(requestId);
}
}

View File

@@ -0,0 +1,65 @@
/**
* Application Use Case: RequestProtestDefenseUseCase
*
* Allows a steward to request defense from the accused driver before making a decision.
* This will trigger a notification to the accused driver.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles';
export interface RequestProtestDefenseCommand {
protestId: string;
stewardId: string;
}
export interface RequestProtestDefenseResult {
success: boolean;
accusedDriverId: string;
protestId: string;
}
export class RequestProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: RequestProtestDefenseCommand): Promise<RequestProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Get the race to find the league
const race = await this.raceRepository.findById(protest.raceId);
if (!race) {
throw new Error('Race not found');
}
// Verify the steward has permission
const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId);
if (!membership || !isLeagueStewardOrHigherRole(membership.role)) {
throw new Error('Only stewards and admins can request defense');
}
// Check if defense can be requested
if (!protest.canRequestDefense()) {
throw new Error('Defense cannot be requested for this protest');
}
// Request defense
const updatedProtest = protest.requestDefense(command.stewardId);
await this.protestRepository.update(updatedProtest);
return {
success: true,
accusedDriverId: protest.accusedDriverId,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,65 @@
/**
* Application Use Case: ReviewProtestUseCase
*
* Allows a steward to review a protest and make a decision (uphold or dismiss).
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import {
EntityNotFoundError,
PermissionDeniedError,
} from '../errors/RacingApplicationError';
export interface ReviewProtestCommand {
protestId: string;
stewardId: string;
decision: 'uphold' | 'dismiss';
decisionNotes: string;
}
export class ReviewProtestUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: ReviewProtestCommand): Promise<void> {
// Load the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new EntityNotFoundError({ entity: 'protest', id: command.protestId });
}
// Load the race to get league ID
const race = await this.raceRepository.findById(protest.raceId);
if (!race) {
throw new EntityNotFoundError({ entity: 'race', id: protest.raceId });
}
// 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 PermissionDeniedError(
'NOT_LEAGUE_ADMIN',
'Only league owners and admins can review protests',
);
}
// Apply the decision
let updatedProtest;
if (command.decision === 'uphold') {
updatedProtest = protest.uphold(command.stewardId, command.decisionNotes);
} else {
updatedProtest = protest.dismiss(command.stewardId, command.decisionNotes);
}
await this.protestRepository.update(updatedProtest);
}
}

View File

@@ -0,0 +1,460 @@
import { Season } from '../../domain/entities/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { Weekday } from '../../domain/types/Weekday';
import { normalizeVisibility } from '../dto/LeagueConfigFormDTO';
import { LeagueVisibility } from '../../domain/value-objects/LeagueVisibility';
import { v4 as uuidv4 } from 'uuid';
/**
* DTOs and helpers shared across Season-focused use cases.
*/
export interface CreateSeasonForLeagueCommand {
leagueId: string;
name: string;
gameId: string;
sourceSeasonId?: string;
/**
* Optional high-level wizard config used to derive schedule/scoring/drop/stewarding.
* When omitted, the Season will be created with minimal metadata only.
*/
config?: LeagueConfigFormModel;
}
export interface CreateSeasonForLeagueResultDTO {
seasonId: string;
}
export interface SeasonSummaryDTO {
seasonId: string;
leagueId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
}
export interface ListSeasonsForLeagueQuery {
leagueId: string;
}
export interface ListSeasonsForLeagueResultDTO {
items: SeasonSummaryDTO[];
}
export interface GetSeasonDetailsQuery {
leagueId: string;
seasonId: string;
}
export interface SeasonDetailsDTO {
seasonId: string;
leagueId: string;
gameId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
maxDrivers?: number;
schedule?: {
startDate: Date;
plannedRounds: number;
};
scoring?: {
scoringPresetId: string;
customScoringEnabled: boolean;
};
dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
n?: number;
};
stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export type SeasonLifecycleTransition =
| 'activate'
| 'complete'
| 'archive'
| 'cancel';
export interface ManageSeasonLifecycleCommand {
leagueId: string;
seasonId: string;
transition: SeasonLifecycleTransition;
}
export interface ManageSeasonLifecycleResultDTO {
seasonId: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
}
/**
* CreateSeasonForLeagueUseCase
*
* Creates a new Season for an existing League, optionally cloning or deriving
* configuration from a source Season or a league config form.
*/
export class CreateSeasonForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: CreateSeasonForLeagueCommand,
): Promise<CreateSeasonForLeagueResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
let baseSeasonProps: {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} = {};
if (command.sourceSeasonId) {
const source = await this.seasonRepository.findById(command.sourceSeasonId);
if (!source) {
throw new Error(`Source Season not found: ${command.sourceSeasonId}`);
}
baseSeasonProps = {
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
...(source.scoringConfig !== undefined
? { scoringConfig: source.scoringConfig }
: {}),
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
...(source.stewardingConfig !== undefined
? { stewardingConfig: source.stewardingConfig }
: {}),
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
};
} else if (command.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
}
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: command.name,
year: new Date().getFullYear(),
status: 'planned',
...(baseSeasonProps?.schedule
? { schedule: baseSeasonProps.schedule }
: {}),
...(baseSeasonProps?.scoringConfig
? { scoringConfig: baseSeasonProps.scoringConfig }
: {}),
...(baseSeasonProps?.dropPolicy
? { dropPolicy: baseSeasonProps.dropPolicy }
: {}),
...(baseSeasonProps?.stewardingConfig
? { stewardingConfig: baseSeasonProps.stewardingConfig }
: {}),
...(baseSeasonProps?.maxDrivers !== undefined
? { maxDrivers: baseSeasonProps.maxDrivers }
: {}),
});
await this.seasonRepository.add(season);
return { seasonId };
}
private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
});
const structure = config.structure;
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
: undefined;
return {
...(schedule !== undefined ? { schedule } : {}),
scoringConfig,
dropPolicy,
stewardingConfig,
...(maxDrivers !== undefined ? { maxDrivers } : {}),
};
}
private buildScheduleFromTimings(
config: LeagueConfigFormModel,
): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned
: timings.sessionCount;
const recurrence = (() => {
const weekdays: WeekdaySet =
timings.weekdays && timings.weekdays.length > 0
? WeekdaySet.fromArray(
timings.weekdays as unknown as Weekday[],
)
: WeekdaySet.fromArray(['Mon']);
switch (timings.recurrenceStrategy) {
case 'everyNWeeks':
return RecurrenceStrategyFactory.everyNWeeks(
timings.intervalWeeks ?? 2,
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':
default:
return RecurrenceStrategyFactory.weekly(weekdays);
}
})();
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds,
});
}
}
/**
* ListSeasonsForLeagueUseCase
*/
export class ListSeasonsForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
query: ListSeasonsForLeagueQuery,
): Promise<ListSeasonsForLeagueResultDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const seasons = await this.seasonRepository.listByLeague(league.id);
const items: SeasonSummaryDTO[] = seasons.map((s) => ({
seasonId: s.id,
leagueId: s.leagueId,
name: s.name,
status: s.status,
...(s.startDate !== undefined ? { startDate: s.startDate } : {}),
...(s.endDate !== undefined ? { endDate: s.endDate } : {}),
// League currently does not track primarySeasonId, so mark false for now.
isPrimary: false,
}));
return { items };
}
}
/**
* GetSeasonDetailsUseCase
*/
export class GetSeasonDetailsUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(query: GetSeasonDetailsQuery): Promise<SeasonDetailsDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${query.seasonId} does not belong to league ${league.id}`,
);
}
return {
seasonId: season.id,
leagueId: season.leagueId,
gameId: season.gameId,
name: season.name,
status: season.status,
...(season.startDate !== undefined ? { startDate: season.startDate } : {}),
...(season.endDate !== undefined ? { endDate: season.endDate } : {}),
...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}),
...(season.schedule
? {
schedule: {
startDate: season.schedule.startDate,
plannedRounds: season.schedule.plannedRounds,
},
}
: {}),
...(season.scoringConfig
? {
scoring: {
scoringPresetId: season.scoringConfig.scoringPresetId,
customScoringEnabled:
season.scoringConfig.customScoringEnabled ?? false,
},
}
: {}),
...(season.dropPolicy
? {
dropPolicy: {
strategy: season.dropPolicy.strategy,
...(season.dropPolicy.n !== undefined
? { n: season.dropPolicy.n }
: {}),
},
}
: {}),
...(season.stewardingConfig
? {
stewarding: {
decisionMode: season.stewardingConfig.decisionMode,
...(season.stewardingConfig.requiredVotes !== undefined
? { requiredVotes: season.stewardingConfig.requiredVotes }
: {}),
requireDefense: season.stewardingConfig.requireDefense,
defenseTimeLimit: season.stewardingConfig.defenseTimeLimit,
voteTimeLimit: season.stewardingConfig.voteTimeLimit,
protestDeadlineHours:
season.stewardingConfig.protestDeadlineHours,
stewardingClosesHours:
season.stewardingConfig.stewardingClosesHours,
notifyAccusedOnProtest:
season.stewardingConfig.notifyAccusedOnProtest,
notifyOnVoteRequired:
season.stewardingConfig.notifyOnVoteRequired,
},
}
: {}),
};
}
}
/**
* ManageSeasonLifecycleUseCase
*/
export class ManageSeasonLifecycleUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: ManageSeasonLifecycleCommand,
): Promise<ManageSeasonLifecycleResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
const season = await this.seasonRepository.findById(command.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${command.seasonId} does not belong to league ${league.id}`,
);
}
let updated: Season;
switch (command.transition) {
case 'activate':
updated = season.activate();
break;
case 'complete':
updated = season.complete();
break;
case 'archive':
updated = season.archive();
break;
case 'cancel':
updated = season.cancel();
break;
default:
throw new Error(`Unsupported Season lifecycle transition`);
}
await this.seasonRepository.update(updated);
return {
seasonId: updated.id,
status: updated.status,
...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}),
...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}),
};
}
}

View File

@@ -0,0 +1,158 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
/**
* Use Case: SendFinalResultsUseCase
*
* Triggered by RaceEventStewardingClosed domain event.
* Sends final results modal notifications to all drivers who participated,
* including any penalty adjustments applied during stewarding.
*/
export class SendFinalResultsUseCase implements UseCase<RaceEventStewardingClosedEvent, void, void, void> {
constructor(
private readonly notificationService: INotificationService,
private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository,
) {}
async execute(event: RaceEventStewardingClosedEvent): Promise<void> {
const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData;
// Get race event to include context
const raceEvent = await this.raceEventRepository.findById(raceEventId);
if (!raceEvent) {
console.warn(`RaceEvent ${raceEventId} not found, skipping final results notifications`);
return;
}
// Get final results for the main race session
const mainRaceSession = raceEvent.getMainRaceSession();
if (!mainRaceSession) {
console.warn(`No main race session found for RaceEvent ${raceEventId}`);
return;
}
const results = await this.resultRepository.findByRaceId(mainRaceSession.id);
// Send final results to each participating driver
for (const driverId of driverIds) {
const driverResult = results.find(r => r.driverId === driverId);
await this.sendFinalResultsNotification(
driverId,
raceEvent,
driverResult,
leagueId,
hadPenaltiesApplied
);
}
}
private async sendFinalResultsNotification(
driverId: string,
raceEvent: any, // RaceEvent type
driverResult: any, // Result type
leagueId: string,
hadPenaltiesApplied: boolean
): Promise<void> {
const position = driverResult?.position ?? 'DNF';
const positionChange = driverResult?.getPositionChange() ?? 0;
const incidents = driverResult?.incidents ?? 0;
// Calculate final rating change (could include penalty adjustments)
const finalRatingChange = this.calculateFinalRatingChange(
driverResult?.position,
driverResult?.incidents,
hadPenaltiesApplied
);
const title = `Final Results: ${raceEvent.name}`;
const body = this.buildFinalResultsBody(
position,
positionChange,
incidents,
finalRatingChange,
hadPenaltiesApplied
);
await this.notificationService.sendNotification({
recipientId: driverId,
type: 'race_final_results' as NotificationType,
title,
body,
channel: 'in_app',
urgency: 'modal',
data: {
raceEventId: raceEvent.id,
sessionId: raceEvent.getMainRaceSession()?.id,
leagueId,
position,
positionChange,
incidents,
finalRatingChange,
hadPenaltiesApplied,
},
actions: [
{
label: 'View Championship Standings',
type: 'primary',
href: `/leagues/${leagueId}/standings`,
},
{
label: 'Race Details',
type: 'secondary',
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
},
],
requiresResponse: false, // Can be dismissed, shows final results
});
}
private buildFinalResultsBody(
position: number | 'DNF',
positionChange: number,
incidents: number,
finalRatingChange: number,
hadPenaltiesApplied: boolean
): string {
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
positionChange < 0 ? `${positionChange}` : '±0';
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
const ratingText = finalRatingChange >= 0 ?
`+${finalRatingChange} rating` :
`${finalRatingChange} rating`;
const penaltyText = hadPenaltiesApplied ?
' (including stewarding adjustments)' : '';
return `Final result: ${positionText} (${positionChangeText} positions). ${incidentsText} ${ratingText}${penaltyText}.`;
}
private calculateFinalRatingChange(
position?: number,
incidents?: number,
hadPenaltiesApplied?: boolean
): number {
if (!position) return -10; // DNF penalty
// Base calculation (same as provisional)
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
const incidentPenalty = (incidents ?? 0) * -5;
let finalChange = baseChange + positionBonus + incidentPenalty;
// Additional penalty adjustments if stewarding applied penalties
if (hadPenaltiesApplied) {
// In a real implementation, this would check actual penalties applied
// For now, we'll assume some penalties might have been applied
finalChange = Math.max(finalChange - 5, -20); // Cap penalty at -20
}
return finalChange;
}
}

View File

@@ -0,0 +1,125 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
/**
* Use Case: SendPerformanceSummaryUseCase
*
* Triggered by MainRaceCompleted domain event.
* Sends immediate performance summary modal notifications to all drivers who participated in the main race.
*/
export class SendPerformanceSummaryUseCase implements UseCase<MainRaceCompletedEvent, void, void, void> {
constructor(
private readonly notificationService: INotificationService,
private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository,
) {}
async execute(event: MainRaceCompletedEvent): Promise<void> {
const { raceEventId, sessionId, leagueId, driverIds } = event.eventData;
// Get race event to include context
const raceEvent = await this.raceEventRepository.findById(raceEventId);
if (!raceEvent) {
console.warn(`RaceEvent ${raceEventId} not found, skipping performance summary notifications`);
return;
}
// Get results for the main race session to calculate performance data
const results = await this.resultRepository.findByRaceId(sessionId);
// Send performance summary to each participating driver
for (const driverId of driverIds) {
const driverResult = results.find(r => r.driverId === driverId);
await this.sendPerformanceSummaryNotification(
driverId,
raceEvent,
driverResult,
leagueId
);
}
}
private async sendPerformanceSummaryNotification(
driverId: string,
raceEvent: any, // RaceEvent type
driverResult: any, // Result type
leagueId: string
): Promise<void> {
const position = driverResult?.position ?? 'DNF';
const positionChange = driverResult?.getPositionChange() ?? 0;
const incidents = driverResult?.incidents ?? 0;
// Calculate provisional rating change (simplified version)
const provisionalRatingChange = this.calculateProvisionalRatingChange(
driverResult?.position,
driverResult?.incidents
);
const title = `Race Complete: ${raceEvent.name}`;
const body = this.buildPerformanceSummaryBody(
position,
positionChange,
incidents,
provisionalRatingChange
);
await this.notificationService.sendNotification({
recipientId: driverId,
type: 'race_performance_summary' as NotificationType,
title,
body,
channel: 'in_app',
urgency: 'modal',
data: {
raceEventId: raceEvent.id,
sessionId: raceEvent.getMainRaceSession()?.id,
leagueId,
position,
positionChange,
incidents,
provisionalRatingChange,
},
actions: [
{
label: 'View Full Results',
type: 'primary',
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
},
],
requiresResponse: false, // Can be dismissed, but shows performance data
});
}
private buildPerformanceSummaryBody(
position: number | 'DNF',
positionChange: number,
incidents: number,
provisionalRatingChange: number
): string {
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
positionChange < 0 ? `${positionChange}` : '±0';
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
const ratingText = provisionalRatingChange >= 0 ?
`+${provisionalRatingChange} rating` :
`${provisionalRatingChange} rating`;
return `You finished ${positionText} (${positionChangeText} positions). ${incidentsText} Provisional ${ratingText}.`;
}
private calculateProvisionalRatingChange(position?: number, incidents?: number): number {
if (!position) return -10; // DNF penalty
// Simplified rating calculation (matches existing GetRaceDetailUseCase logic)
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
const incidentPenalty = (incidents ?? 0) * -5;
return baseChange + positionBonus + incidentPenalty;
}
}

View File

@@ -0,0 +1,52 @@
/**
* Application Use Case: SubmitProtestDefenseUseCase
*
* Allows the accused driver to submit their defense statement for a protest.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
export interface SubmitProtestDefenseCommand {
protestId: string;
driverId: string;
statement: string;
videoUrl?: string;
}
export interface SubmitProtestDefenseResult {
success: boolean;
protestId: string;
}
export class SubmitProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
) {}
async execute(command: SubmitProtestDefenseCommand): Promise<SubmitProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Verify the submitter is the accused driver
if (protest.accusedDriverId !== command.driverId) {
throw new Error('Only the accused driver can submit a defense');
}
// Check if defense can be submitted
if (!protest.canSubmitDefense()) {
throw new Error('Defense cannot be submitted for this protest');
}
// Submit defense
const updatedProtest = protest.submitDefense(command.statement, command.videoUrl);
await this.protestRepository.update(updatedProtest);
return {
success: true,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,56 @@
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type {
LeagueMembership,
MembershipRole,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
export interface TransferLeagueOwnershipCommandDTO {
leagueId: string;
currentOwnerId: string;
newOwnerId: string;
}
export class TransferLeagueOwnershipUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly membershipRepository: ILeagueMembershipRepository
) {}
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<void> {
const { leagueId, currentOwnerId, newOwnerId } = command;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error('League not found');
}
if (league.ownerId !== currentOwnerId) {
throw new Error('Only the current owner can transfer ownership');
}
const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId);
if (!newOwnerMembership || newOwnerMembership.status !== 'active') {
throw new Error('New owner must be an active member of the league');
}
const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId);
await this.membershipRepository.saveMembership({
...newOwnerMembership,
role: 'owner' as MembershipRole,
});
if (currentOwnerMembership) {
await this.membershipRepository.saveMembership({
...currentOwnerMembership,
role: 'admin' as MembershipRole,
});
}
const updatedLeague = league.update({ ownerId: newOwnerId });
await this.leagueRepository.update(updatedLeague);
}
}

View File

@@ -0,0 +1,35 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { DriverDTO } from '../dto/DriverDTO';
import { EntityMappers } from '../mappers/EntityMappers';
export interface UpdateDriverProfileInput {
driverId: string;
bio?: string;
country?: string;
}
/**
* Application use case responsible for updating basic driver profile details.
* Encapsulates domain entity mutation and mapping to a DriverDTO.
*/
export class UpdateDriverProfileUseCase {
constructor(private readonly driverRepository: IDriverRepository) {}
async execute(input: UpdateDriverProfileInput): Promise<DriverDTO | null> {
const { driverId, bio, country } = input;
const existing = await this.driverRepository.findById(driverId);
if (!existing) {
return null;
}
const updated = existing.update({
...(bio !== undefined ? { bio } : {}),
...(country !== undefined ? { country } : {}),
});
const persisted = await this.driverRepository.update(updated);
const dto = EntityMappers.toDriverDTO(persisted);
return dto ?? null;
}
}

View File

@@ -0,0 +1,34 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { Team } from '../../domain/entities/Team';
import type { UpdateTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class UpdateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: UpdateTeamCommandDTO): Promise<void> {
const { teamId, updates, updatedBy } = command;
const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy);
if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) {
throw new Error('Only owners and managers can update team info');
}
const existing = await this.teamRepository.findById(teamId);
if (!existing) {
throw new Error('Team not found');
}
const updated = existing.update({
...(updates.name !== undefined && { name: updates.name }),
...(updates.tag !== undefined && { tag: updates.tag }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.leagues !== undefined && { leagues: updates.leagues }),
});
await this.teamRepository.update(updated);
}
}

View File

@@ -0,0 +1,22 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { WithdrawFromRaceCommandDTO } from '../dto/WithdrawFromRaceCommandDTO';
/**
* Mirrors legacy withdrawFromRace behavior:
* - throws when driver is not registered
* - removes registration and cleans up empty race sets
*
* The repository encapsulates the in-memory or persistent details.
*/
export class WithdrawFromRaceUseCase {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(command: WithdrawFromRaceCommandDTO): Promise<void> {
const { raceId, driverId } = command;
// Let repository enforce "not registered" error behavior to match legacy logic.
await this.registrationRepository.withdraw(raceId, driverId);
}
}

View File

@@ -0,0 +1,2 @@
// Use cases will be added as needed
// Example: CreateDriverUseCase, CreateLeagueUseCase, etc.