/** * 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 '@core/shared/domain'; import { ProtestId } from './ProtestId'; import { RaceId } from './RaceId'; import { DriverId } from './DriverId'; import { StewardId } from './StewardId'; import { ProtestStatus } from './ProtestStatus'; import { ProtestIncident } from './ProtestIncident'; import { ProtestComment } from './ProtestComment'; import { VideoUrl } from './VideoUrl'; import { FiledAt } from './FiledAt'; import { ReviewedAt } from './ReviewedAt'; import { ProtestDefense } from './ProtestDefense'; import { DefenseRequestedAt } from './DefenseRequestedAt'; import { DecisionNotes } from './DecisionNotes'; export interface ProtestProps { id: ProtestId; raceId: RaceId; /** The driver filing the protest */ protestingDriverId: DriverId; /** The driver being protested against */ accusedDriverId: DriverId; /** Details of the incident */ incident: ProtestIncident; /** Optional comment/statement from the protesting driver */ comment?: ProtestComment; /** URL to proof video clip */ proofVideoUrl?: VideoUrl; /** Current status of the protest */ status: ProtestStatus; /** ID of the steward/admin who reviewed (if any) */ reviewedBy?: StewardId; /** Decision notes from the steward */ decisionNotes?: DecisionNotes; /** Timestamp when the protest was filed */ filedAt: FiledAt; /** Timestamp when the protest was reviewed */ reviewedAt?: ReviewedAt; /** Defense from the accused driver (if requested and submitted) */ defense?: ProtestDefense; /** Timestamp when defense was requested */ defenseRequestedAt?: DefenseRequestedAt; /** ID of the steward who requested defense */ defenseRequestedBy?: StewardId; } export class Protest implements IEntity { private constructor(private readonly props: ProtestProps) {} static create(props: { id: string; raceId: string; protestingDriverId: string; accusedDriverId: string; incident: { lap: number; description: string; timeInRace?: number }; comment?: string; proofVideoUrl?: string; status?: string; reviewedBy?: string; decisionNotes?: string; filedAt?: Date; reviewedAt?: Date; defense?: { statement: string; videoUrl?: string; submittedAt: Date }; defenseRequestedAt?: Date; defenseRequestedBy?: string; }): Protest { const id = ProtestId.create(props.id); const raceId = RaceId.create(props.raceId); const protestingDriverId = DriverId.create(props.protestingDriverId); const accusedDriverId = DriverId.create(props.accusedDriverId); const incident = ProtestIncident.create(props.incident.lap, props.incident.description, props.incident.timeInRace); const comment = props.comment ? ProtestComment.create(props.comment) : undefined; const proofVideoUrl = props.proofVideoUrl ? VideoUrl.create(props.proofVideoUrl) : undefined; const status = ProtestStatus.create(props.status || 'pending'); const reviewedBy = props.reviewedBy ? StewardId.create(props.reviewedBy) : undefined; const decisionNotes = props.decisionNotes ? DecisionNotes.create(props.decisionNotes) : undefined; const filedAt = FiledAt.create(props.filedAt || new Date()); const reviewedAt = props.reviewedAt ? ReviewedAt.create(props.reviewedAt) : undefined; const defense = props.defense ? ProtestDefense.create(props.defense.statement, props.defense.submittedAt, props.defense.videoUrl) : undefined; const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined; const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined; const protestProps: ProtestProps = { id, raceId, protestingDriverId, accusedDriverId, incident, status, filedAt, }; if (comment !== undefined) protestProps.comment = comment; if (proofVideoUrl !== undefined) protestProps.proofVideoUrl = proofVideoUrl; if (reviewedBy !== undefined) protestProps.reviewedBy = reviewedBy; if (decisionNotes !== undefined) protestProps.decisionNotes = decisionNotes; if (reviewedAt !== undefined) protestProps.reviewedAt = reviewedAt; if (defense !== undefined) protestProps.defense = defense; if (defenseRequestedAt !== undefined) protestProps.defenseRequestedAt = defenseRequestedAt; if (defenseRequestedBy !== undefined) protestProps.defenseRequestedBy = defenseRequestedBy; return new Protest(protestProps); } static rehydrate(props: { id: string; raceId: string; protestingDriverId: string; accusedDriverId: string; incident: { lap: number; description: string; timeInRace?: number }; comment?: string; proofVideoUrl?: string; status: string; reviewedBy?: string; decisionNotes?: string; filedAt: Date; reviewedAt?: Date; defense?: { statement: string; videoUrl?: string; submittedAt: Date }; defenseRequestedAt?: Date; defenseRequestedBy?: string; }): Protest { const id = ProtestId.create(props.id); const raceId = RaceId.create(props.raceId); const protestingDriverId = DriverId.create(props.protestingDriverId); const accusedDriverId = DriverId.create(props.accusedDriverId); const incident = ProtestIncident.create(props.incident.lap, props.incident.description, props.incident.timeInRace); const comment = props.comment ? ProtestComment.create(props.comment) : undefined; const proofVideoUrl = props.proofVideoUrl ? VideoUrl.create(props.proofVideoUrl) : undefined; const status = ProtestStatus.create(props.status); const reviewedBy = props.reviewedBy ? StewardId.create(props.reviewedBy) : undefined; const decisionNotes = props.decisionNotes ? DecisionNotes.create(props.decisionNotes) : undefined; const filedAt = FiledAt.create(props.filedAt); const reviewedAt = props.reviewedAt ? ReviewedAt.create(props.reviewedAt) : undefined; const defense = props.defense ? ProtestDefense.create(props.defense.statement, props.defense.submittedAt, props.defense.videoUrl) : undefined; const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined; const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined; const protestProps: ProtestProps = { id, raceId, protestingDriverId, accusedDriverId, incident, status, filedAt, }; if (comment !== undefined) protestProps.comment = comment; if (proofVideoUrl !== undefined) protestProps.proofVideoUrl = proofVideoUrl; if (reviewedBy !== undefined) protestProps.reviewedBy = reviewedBy; if (decisionNotes !== undefined) protestProps.decisionNotes = decisionNotes; if (reviewedAt !== undefined) protestProps.reviewedAt = reviewedAt; if (defense !== undefined) protestProps.defense = defense; if (defenseRequestedAt !== undefined) protestProps.defenseRequestedAt = defenseRequestedAt; if (defenseRequestedBy !== undefined) protestProps.defenseRequestedBy = defenseRequestedBy; return new Protest(protestProps); } get id(): string { return this.props.id.toString(); } get raceId(): string { return this.props.raceId.toString(); } get protestingDriverId(): string { return this.props.protestingDriverId.toString(); } get accusedDriverId(): string { return this.props.accusedDriverId.toString(); } get incident(): ProtestIncident { return this.props.incident; } get comment(): string | undefined { return this.props.comment?.toString(); } get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl?.toString(); } get status(): ProtestStatus { return this.props.status; } get reviewedBy(): string | undefined { return this.props.reviewedBy?.toString(); } get decisionNotes(): string | undefined { return this.props.decisionNotes?.toString(); } get filedAt(): Date { return this.props.filedAt.toDate(); } get reviewedAt(): Date | undefined { return this.props.reviewedAt?.toDate(); } get defense(): ProtestDefense | undefined { return this.props.defense; } get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt?.toDate(); } get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy?.toString(); } isPending(): boolean { return this.props.status.toString() === 'pending'; } isAwaitingDefense(): boolean { return this.props.status.toString() === 'awaiting_defense'; } isUnderReview(): boolean { return this.props.status.toString() === 'under_review'; } isResolved(): boolean { return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status.toString()); } 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: ProtestStatus.create('awaiting_defense'), defenseRequestedAt: DefenseRequestedAt.create(new Date()), defenseRequestedBy: StewardId.create(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 defense = ProtestDefense.create(statement.trim(), new Date(), videoUrl); return new Protest({ ...this.props, status: ProtestStatus.create('under_review'), defense, }); } /** * 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: ProtestStatus.create('under_review'), reviewedBy: StewardId.create(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: ProtestStatus.create('upheld'), reviewedBy: StewardId.create(stewardId), decisionNotes: DecisionNotes.create(decisionNotes), reviewedAt: ReviewedAt.create(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: ProtestStatus.create('dismissed'), reviewedBy: StewardId.create(stewardId), decisionNotes: DecisionNotes.create(decisionNotes), reviewedAt: ReviewedAt.create(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: ProtestStatus.create('withdrawn'), reviewedAt: ReviewedAt.create(new Date()), }); } }