This commit is contained in:
2025-12-09 22:22:06 +01:00
parent e34a11ae7c
commit 3adf2e5e94
62 changed files with 6079 additions and 998 deletions

View File

@@ -118,6 +118,7 @@ export class League {
update(props: Partial<{
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
}>): League {
@@ -125,7 +126,7 @@ export class League {
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: this.ownerId,
ownerId: props.ownerId ?? this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,

View File

@@ -5,13 +5,16 @@
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
export type PenaltyType =
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
| 'grid_penalty' // Grid position penalty for next race
| 'points_deduction' // Deduct championship points
| 'disqualification' // DSQ from the race
| 'warning' // Official warning (no immediate consequence)
| 'license_points'; // Add penalty points to license (future feature)
| 'license_points' // Add penalty points to license (safety rating)
| 'probation' // Conditional penalty
| 'fine' // Monetary/points fine
| 'race_ban'; // Multi-race suspension
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
@@ -52,7 +55,7 @@ export class Penalty {
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction'].includes(props.type)) {
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
if (props.value === undefined || props.value <= 0) {
throw new Error(`${props.type} requires a positive value`);
}
@@ -135,6 +138,12 @@ export class Penalty {
return 'Official warning';
case 'license_points':
return `${this.props.value} license penalty points`;
case 'probation':
return 'Probationary period';
case 'fine':
return `${this.props.value} points fine`;
case 'race_ban':
return `${this.props.value} race suspension`;
default:
return 'Penalty';
}

View File

@@ -1,10 +1,18 @@
/**
* 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
*/
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export interface ProtestIncident {
/** Lap number where the incident occurred */
@@ -15,6 +23,15 @@ export interface ProtestIncident {
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;
@@ -38,6 +55,12 @@ export interface ProtestProps {
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 {
@@ -71,11 +94,18 @@ export class Protest {
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';
}
@@ -84,12 +114,60 @@ export class Protest {
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();
}
/**
* Start reviewing the protest
* Request defense from the accused driver
*/
requestDefense(stewardId: string): Protest {
if (!this.canRequestDefense()) {
throw new Error('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 Error('Defense can only be submitted when protest is awaiting defense');
}
if (!statement?.trim()) {
throw new Error('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()) {
throw new Error('Only pending protests can be put under review');
if (!this.isPending() && !this.isAwaitingDefense()) {
throw new Error('Only pending or awaiting-defense protests can be put under review');
}
return new Protest({
...this.props,
@@ -102,8 +180,8 @@ export class Protest {
* Uphold the protest (finding the accused guilty)
*/
uphold(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be upheld');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
}
return new Protest({
...this.props,
@@ -118,8 +196,8 @@ export class Protest {
* Dismiss the protest (finding no fault)
*/
dismiss(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be dismissed');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
}
return new Protest({
...this.props,

View File

@@ -0,0 +1,73 @@
/**
* Domain Value Object: LeagueRoles
*
* Utility functions for working with league membership roles.
*/
import type { MembershipRole } from '../entities/LeagueMembership';
/**
* Role hierarchy (higher number = more authority)
*/
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
member: 0,
steward: 1,
admin: 2,
owner: 3,
};
/**
* Check if a role is at least steward level
*/
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
}
/**
* Check if a role is at least admin level
*/
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
}
/**
* Check if a role is owner
*/
export function isLeagueOwnerRole(role: MembershipRole): boolean {
return role === 'owner';
}
/**
* Compare two roles
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
*/
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
}
/**
* Get role display name
*/
export function getRoleDisplayName(role: MembershipRole): string {
const names: Record<MembershipRole, string> = {
member: 'Member',
steward: 'Steward',
admin: 'Admin',
owner: 'Owner',
};
return names[role];
}
/**
* Get all roles in order of hierarchy
*/
export function getAllRolesOrdered(): MembershipRole[] {
return ['member', 'steward', 'admin', 'owner'];
}
/**
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
*/
export function getAssignableRoles(): MembershipRole[] {
return ['member', 'steward', 'admin'];
}