/** * Domain Entity: Protest * * Represents a protest filed by a driver against another driver for an incident during a race. * * Workflow states: * - pending: Initial state when protest is filed * - awaiting_defense: Defense has been requested from the accused driver * - under_review: Steward is actively reviewing the protest * - upheld: Protest was upheld (penalty will be applied) * - dismissed: Protest was dismissed (no action taken) * - withdrawn: Protesting driver withdrew the protest */ import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@gridpilot/shared/domain'; export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn'; export interface ProtestIncident { /** Lap number where the incident occurred */ lap: number; /** Time in the race (seconds from start, or timestamp) */ timeInRace?: number; /** Brief description of the incident */ description: string; } export interface ProtestDefense { /** The accused driver's statement/defense */ statement: string; /** URL to defense video clip (optional) */ videoUrl?: string; /** Timestamp when defense was submitted */ submittedAt: Date; } export interface ProtestProps { id: string; raceId: string; /** The driver filing the protest */ protestingDriverId: string; /** The driver being protested against */ accusedDriverId: string; /** Details of the incident */ incident: ProtestIncident; /** Optional comment/statement from the protesting driver */ comment?: string; /** URL to proof video clip */ proofVideoUrl?: string; /** Current status of the protest */ status: ProtestStatus; /** ID of the steward/admin who reviewed (if any) */ reviewedBy?: string; /** Decision notes from the steward */ decisionNotes?: string; /** Timestamp when the protest was filed */ filedAt: Date; /** Timestamp when the protest was reviewed */ reviewedAt?: Date; /** Defense from the accused driver (if requested and submitted) */ defense?: ProtestDefense; /** Timestamp when defense was requested */ defenseRequestedAt?: Date; /** ID of the steward who requested defense */ defenseRequestedBy?: string; } export class Protest implements IEntity { private constructor(private readonly props: ProtestProps) {} static create(props: ProtestProps): Protest { if (!props.id) throw new RacingDomainValidationError('Protest ID is required'); if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required'); if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required'); if (!props.incident) throw new RacingDomainValidationError('Incident details are required'); if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative'); if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required'); return new Protest({ ...props, status: props.status || 'pending', filedAt: props.filedAt || new Date(), }); } get id(): string { return this.props.id; } get raceId(): string { return this.props.raceId; } get protestingDriverId(): string { return this.props.protestingDriverId; } get accusedDriverId(): string { return this.props.accusedDriverId; } get incident(): ProtestIncident { return { ...this.props.incident }; } get comment(): string | undefined { return this.props.comment; } get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; } get status(): ProtestStatus { return this.props.status; } get reviewedBy(): string | undefined { return this.props.reviewedBy; } get decisionNotes(): string | undefined { return this.props.decisionNotes; } get filedAt(): Date { return this.props.filedAt; } get reviewedAt(): Date | undefined { return this.props.reviewedAt; } get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; } get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; } get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; } isPending(): boolean { return this.props.status === 'pending'; } isAwaitingDefense(): boolean { return this.props.status === 'awaiting_defense'; } isUnderReview(): boolean { return this.props.status === 'under_review'; } isResolved(): boolean { return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status); } hasDefense(): boolean { return this.props.defense !== undefined; } canRequestDefense(): boolean { return this.isPending() && !this.hasDefense() && !this.props.defenseRequestedAt; } canSubmitDefense(): boolean { return this.isAwaitingDefense() && !this.hasDefense(); } /** * Request defense from the accused driver */ requestDefense(stewardId: string): Protest { if (!this.canRequestDefense()) { throw new RacingDomainInvariantError('Defense can only be requested for pending protests without existing defense'); } return new Protest({ ...this.props, status: 'awaiting_defense', defenseRequestedAt: new Date(), defenseRequestedBy: stewardId, }); } /** * Submit defense from the accused driver */ submitDefense(statement: string, videoUrl?: string): Protest { if (!this.canSubmitDefense()) { throw new RacingDomainInvariantError('Defense can only be submitted when protest is awaiting defense'); } if (!statement?.trim()) { throw new RacingDomainValidationError('Defense statement is required'); } const defenseBase: ProtestDefense = { statement: statement.trim(), submittedAt: new Date(), }; const nextDefense: ProtestDefense = videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase; return new Protest({ ...this.props, status: 'under_review', defense: nextDefense, }); } /** * Start reviewing the protest (without requiring defense) */ startReview(stewardId: string): Protest { if (!this.isPending() && !this.isAwaitingDefense()) { throw new RacingDomainInvariantError('Only pending or awaiting-defense protests can be put under review'); } return new Protest({ ...this.props, status: 'under_review', reviewedBy: stewardId, }); } /** * Uphold the protest (finding the accused guilty) */ uphold(stewardId: string, decisionNotes: string): Protest { if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) { throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be upheld'); } return new Protest({ ...this.props, status: 'upheld', reviewedBy: stewardId, decisionNotes, reviewedAt: new Date(), }); } /** * Dismiss the protest (finding no fault) */ dismiss(stewardId: string, decisionNotes: string): Protest { if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) { throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be dismissed'); } return new Protest({ ...this.props, status: 'dismissed', reviewedBy: stewardId, decisionNotes, reviewedAt: new Date(), }); } /** * Withdraw the protest (by the protesting driver) */ withdraw(): Protest { if (this.isResolved()) { throw new RacingDomainInvariantError('Cannot withdraw a resolved protest'); } return new Protest({ ...this.props, status: 'withdrawn', reviewedAt: new Date(), }); } }