wip
This commit is contained in:
@@ -31,6 +31,8 @@ export * from './use-cases/ReviewProtestUseCase';
|
||||
export * from './use-cases/ApplyPenaltyUseCase';
|
||||
export * from './use-cases/GetRaceProtestsQuery';
|
||||
export * from './use-cases/GetRacePenaltiesQuery';
|
||||
export * from './use-cases/RequestProtestDefenseUseCase';
|
||||
export * from './use-cases/SubmitProtestDefenseUseCase';
|
||||
|
||||
// Export ports
|
||||
export * from './ports/DriverRatingProvider';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Application Use Case: RequestProtestDefenseUseCase
|
||||
*
|
||||
* Allows a steward to request defense from the accused driver before making a decision.
|
||||
* This will trigger a notification to the accused driver.
|
||||
*/
|
||||
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import { isLeagueStewardOrHigherRole } from '../../domain/value-objects/LeagueRoles';
|
||||
|
||||
export interface RequestProtestDefenseCommand {
|
||||
protestId: string;
|
||||
stewardId: string;
|
||||
}
|
||||
|
||||
export interface RequestProtestDefenseResult {
|
||||
success: boolean;
|
||||
accusedDriverId: string;
|
||||
protestId: string;
|
||||
}
|
||||
|
||||
export class RequestProtestDefenseUseCase {
|
||||
constructor(
|
||||
private readonly protestRepository: IProtestRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly membershipRepository: ILeagueMembershipRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RequestProtestDefenseCommand): Promise<RequestProtestDefenseResult> {
|
||||
// Get the protest
|
||||
const protest = await this.protestRepository.findById(command.protestId);
|
||||
if (!protest) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
// Get the race to find the league
|
||||
const race = await this.raceRepository.findById(protest.raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
// Verify the steward has permission
|
||||
const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId);
|
||||
if (!membership || !isLeagueStewardOrHigherRole(membership.role)) {
|
||||
throw new Error('Only stewards and admins can request defense');
|
||||
}
|
||||
|
||||
// Check if defense can be requested
|
||||
if (!protest.canRequestDefense()) {
|
||||
throw new Error('Defense cannot be requested for this protest');
|
||||
}
|
||||
|
||||
// Request defense
|
||||
const updatedProtest = protest.requestDefense(command.stewardId);
|
||||
await this.protestRepository.update(updatedProtest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
protestId: protest.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Application Use Case: SubmitProtestDefenseUseCase
|
||||
*
|
||||
* Allows the accused driver to submit their defense statement for a protest.
|
||||
*/
|
||||
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
|
||||
export interface SubmitProtestDefenseCommand {
|
||||
protestId: string;
|
||||
driverId: string;
|
||||
statement: string;
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
export interface SubmitProtestDefenseResult {
|
||||
success: boolean;
|
||||
protestId: string;
|
||||
}
|
||||
|
||||
export class SubmitProtestDefenseUseCase {
|
||||
constructor(
|
||||
private readonly protestRepository: IProtestRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: SubmitProtestDefenseCommand): Promise<SubmitProtestDefenseResult> {
|
||||
// Get the protest
|
||||
const protest = await this.protestRepository.findById(command.protestId);
|
||||
if (!protest) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
// Verify the submitter is the accused driver
|
||||
if (protest.accusedDriverId !== command.driverId) {
|
||||
throw new Error('Only the accused driver can submit a defense');
|
||||
}
|
||||
|
||||
// Check if defense can be submitted
|
||||
if (!protest.canSubmitDefense()) {
|
||||
throw new Error('Defense cannot be submitted for this protest');
|
||||
}
|
||||
|
||||
// Submit defense
|
||||
const updatedProtest = protest.submitDefense(command.statement, command.videoUrl);
|
||||
await this.protestRepository.update(updatedProtest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
protestId: protest.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
ILeagueMembershipRepository,
|
||||
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
import type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
export interface TransferLeagueOwnershipCommandDTO {
|
||||
leagueId: string;
|
||||
currentOwnerId: string;
|
||||
newOwnerId: string;
|
||||
}
|
||||
|
||||
export class TransferLeagueOwnershipUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly membershipRepository: ILeagueMembershipRepository
|
||||
) {}
|
||||
|
||||
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<void> {
|
||||
const { leagueId, currentOwnerId, newOwnerId } = command;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
throw new Error('League not found');
|
||||
}
|
||||
|
||||
if (league.ownerId !== currentOwnerId) {
|
||||
throw new Error('Only the current owner can transfer ownership');
|
||||
}
|
||||
|
||||
const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId);
|
||||
if (!newOwnerMembership || newOwnerMembership.status !== 'active') {
|
||||
throw new Error('New owner must be an active member of the league');
|
||||
}
|
||||
|
||||
const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId);
|
||||
|
||||
await this.membershipRepository.saveMembership({
|
||||
...newOwnerMembership,
|
||||
role: 'owner' as MembershipRole,
|
||||
});
|
||||
|
||||
if (currentOwnerMembership) {
|
||||
await this.membershipRepository.saveMembership({
|
||||
...currentOwnerMembership,
|
||||
role: 'admin' as MembershipRole,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedLeague = league.update({ ownerId: newOwnerId });
|
||||
await this.leagueRepository.update(updatedLeague);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
packages/racing/domain/value-objects/LeagueRoles.ts
Normal file
73
packages/racing/domain/value-objects/LeagueRoles.ts
Normal 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'];
|
||||
}
|
||||
Reference in New Issue
Block a user