Files
gridpilot.gg/packages/racing/domain/entities/Protest.ts
2025-12-11 11:25:22 +01:00

227 lines
7.5 KiB
TypeScript

/**
* Domain Entity: Protest
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
*
* 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
*/
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 {
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');
}
return new Protest({
...this.props,
status: 'under_review',
defense: {
statement: statement.trim(),
videoUrl,
submittedAt: new Date(),
},
});
}
/**
* 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(),
});
}
}