256 lines
9.9 KiB
TypeScript
256 lines
9.9 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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);
|
|
}
|
|
|
|
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()),
|
|
});
|
|
}
|
|
} |