import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; import type { Result as RaceResult } from '../../domain/entities/Result'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; 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: IRaceEventRepository, private readonly resultRepository: IResultRepository, private readonly leagueRepository: ILeagueRepository, private readonly membershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, private readonly output: UseCaseOutputPort, ) {} 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, input.triggeredById); if (!membership || !isLeagueStewardOrHigherRole(membership.role)) { 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, raceEvent, driverResult, league.id, false, ); notificationsSent += 1; } const result: SendFinalResultsResult = { leagueId: league.id, raceId: raceEvent.id, notificationsSent, }; this.output.present(result); return Result.ok(undefined); } 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, 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, }); } 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; } }