import type { Logger } from '@core/shared/domain/Logger'; import { Result } from '@core/shared/domain/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; import { IncidentCount } from '../../domain/entities/result/IncidentCount'; import { Position } from '../../domain/entities/result/Position'; import type { Result as RaceResult } from '../../domain/entities/result/Result'; import { LeagueMembershipRepository } from '../../domain/repositories/LeagueMembershipRepository'; import { LeagueRepository } from '../../domain/repositories/LeagueRepository'; import { RaceEventRepository } from '../../domain/repositories/RaceEventRepository'; import { ResultRepository } from '../../domain/repositories/ResultRepository'; import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles'; export type SendFinalResultsInput = { leagueId: string; raceId: string; triggeredById: string; }; export type SendFinalResultsResult = { leagueId: string; raceId: string; notificationsSent: number; }; export type SendFinalResultsErrorCode = | 'LEAGUE_NOT_FOUND' | 'RACE_NOT_FOUND' | 'INSUFFICIENT_PERMISSIONS' | 'RESULTS_NOT_FINAL' | 'REPOSITORY_ERROR'; /** * Use Case: SendFinalResultsUseCase * * Sends final results notifications to all drivers who participated * in the main race session for a given race event. */ export class SendFinalResultsUseCase { constructor(private readonly notificationService: NotificationService, private readonly raceEventRepository: RaceEventRepository, private readonly resultRepository: ResultRepository, private readonly leagueRepository: LeagueRepository, private readonly membershipRepository: LeagueMembershipRepository, private readonly logger: Logger) {} async execute( input: SendFinalResultsInput, ): Promise>> { try { const league = await this.leagueRepository.findById(input.leagueId); if (!league) { return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); } const raceEvent = await this.raceEventRepository.findById(input.raceId); if (!raceEvent) { return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race event not found' } }); } const membership = await this.membershipRepository.getMembership( league.id.toString(), input.triggeredById, ); if (!membership || !isLeagueStewardOrHigherRole(membership.role.toString())) { return Result.err({ code: 'INSUFFICIENT_PERMISSIONS', details: { message: 'Insufficient permissions to send final results' }, }); } if (raceEvent.status !== 'closed') { return Result.err({ code: 'RESULTS_NOT_FINAL', details: { message: 'Race results are not in a final state' }, }); } const mainRaceSession = raceEvent.getMainRaceSession(); if (!mainRaceSession) { return Result.err({ code: 'RESULTS_NOT_FINAL', details: { message: 'Main race session not found for race event' }, }); } const results = await this.resultRepository.findByRaceId(mainRaceSession.id); let notificationsSent = 0; for (const driverResult of results) { await this.sendFinalResultsNotification( driverResult.driverId.toString(), raceEvent, driverResult, league.id.toString(), false, ); notificationsSent += 1; } const result: SendFinalResultsResult = { leagueId: league.id.toString(), raceId: raceEvent.id, notificationsSent, }; return Result.ok(result); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to send final results'; this.logger.error('SendFinalResultsUseCase.execute failed', error instanceof Error ? error : undefined); return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } } private async sendFinalResultsNotification( driverId: string, raceEvent: RaceEvent, driverResult: RaceResult | undefined, leagueId: string, hadPenaltiesApplied: boolean, ): Promise { const position = driverResult?.position ?? 'DNF'; const positionChange = driverResult?.getPositionChange() ?? 0; const incidents = driverResult?.incidents ?? 0; const finalRatingChange = this.calculateFinalRatingChange( driverResult?.position?.toNumber(), driverResult?.incidents?.toNumber(), hadPenaltiesApplied, ); const title = `Final Results: ${raceEvent.name}`; const positionValue = position instanceof Position ? position.toNumber() : position; const incidentValue = incidents instanceof IncidentCount ? incidents.toNumber() : incidents; const body = this.buildFinalResultsBody( positionValue, positionChange, incidentValue, 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: positionValue, positionChange, incidents: incidentValue, 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, }); } 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; 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; if (hadPenaltiesApplied) { finalChange = Math.max(finalChange - 5, -20); } return finalChange; } }