This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -1,4 +1,4 @@
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef';
export interface ChampionshipStandingsRowDTO {
participant: ParticipantRef;

View File

@@ -53,9 +53,9 @@ export interface LeagueTimingsFormDTO {
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin", or "track" for track local time
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];
weekdays?: import('../../domain/types/Weekday').Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
monthlyWeekday?: import('../../domain/types/Weekday').Weekday;
}
/**

View File

@@ -1,11 +1,11 @@
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
import type { Weekday } from '../../domain/value-objects/Weekday';
import type { Weekday } from '../../domain/types/Weekday';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import type { RecurrenceStrategy } from '../../domain/types/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/types/RecurrenceStrategy';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';

View File

@@ -1,4 +1,8 @@
import type { Team, TeamJoinRequest, TeamMembership } from '../../domain/entities/Team';
import type { Team } from '../../domain/entities/Team';
import type {
TeamJoinRequest,
TeamMembership,
} from '../../domain/types/TeamMembership';
export interface JoinTeamCommandDTO {
teamId: string;

View File

@@ -1,5 +1,12 @@
export abstract class RacingApplicationError extends Error {
import type { IApplicationError, CommonApplicationErrorKind } from '@gridpilot/shared/errors';
export abstract class RacingApplicationError
extends Error
implements IApplicationError<CommonApplicationErrorKind | string, unknown>
{
readonly type = 'application' as const;
readonly context = 'racing-application';
abstract readonly kind: CommonApplicationErrorKind | string;
constructor(message: string) {
super(message);
@@ -22,11 +29,16 @@ export interface EntityNotFoundDetails {
id: string;
}
export class EntityNotFoundError extends RacingApplicationError {
export class EntityNotFoundError
extends RacingApplicationError
implements IApplicationError<'not_found', EntityNotFoundDetails>
{
readonly kind = 'not_found' as const;
readonly details: EntityNotFoundDetails;
constructor(public readonly details: EntityNotFoundDetails) {
constructor(details: EntityNotFoundDetails) {
super(`${details.entity} not found for id: ${details.id}`);
this.details = details;
}
}
@@ -39,15 +51,25 @@ export type PermissionDeniedReason =
| 'TEAM_OWNER_CANNOT_LEAVE'
| 'UNAUTHORIZED';
export class PermissionDeniedError extends RacingApplicationError {
export class PermissionDeniedError
extends RacingApplicationError
implements IApplicationError<'forbidden', PermissionDeniedReason>
{
readonly kind = 'forbidden' as const;
constructor(public readonly reason: PermissionDeniedReason, message?: string) {
super(message ?? `Permission denied: ${reason}`);
}
get details(): PermissionDeniedReason {
return this.reason;
}
}
export class BusinessRuleViolationError extends RacingApplicationError {
export class BusinessRuleViolationError
extends RacingApplicationError
implements IApplicationError<'conflict', undefined>
{
readonly kind = 'conflict' as const;
constructor(message: string) {

View File

@@ -54,13 +54,13 @@ export * from './ports/DriverRatingProvider';
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
export type { Team } from '../domain/entities/Team';
export type {
Team,
TeamMembership,
TeamJoinRequest,
TeamRole,
TeamMembershipStatus,
} from '../domain/entities/Team';
} from '../domain/types/TeamMembership';
export type { DriverDTO } from './dto/DriverDTO';
export type { LeagueDTO } from './dto/LeagueDTO';

View File

@@ -0,0 +1,12 @@
/**
* Application Port: IImageServicePort
*
* Abstraction used by racing application use cases to obtain image URLs
* for drivers, teams and leagues without depending on UI/media layers.
*/
export interface IImageServicePort {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}

View File

@@ -1,4 +1,5 @@
import type { Team, TeamMembership } from '../../domain/entities/Team';
import type { Team } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
export interface DriverTeamViewModel {
team: {

View File

@@ -1,4 +1,5 @@
import type { Team, TeamMembership } from '../../domain/entities/Team';
import type { Team } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
export interface TeamDetailsViewModel {
team: {

View File

@@ -1,4 +1,4 @@
import type { TeamJoinRequest } from '../../domain/entities/Team';
import type { TeamJoinRequest } from '../../domain/types/TeamMembership';
export interface TeamJoinRequestViewModel {
requestId: string;

View File

@@ -1,4 +1,4 @@
import type { TeamMembership } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
export interface TeamMemberViewModel {
driverId: string;

View File

@@ -8,6 +8,7 @@
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface AcceptSponsorshipRequestDTO {
requestId: string;
@@ -23,7 +24,8 @@ export interface AcceptSponsorshipRequestResultDTO {
netAmount: number;
}
export class AcceptSponsorshipRequestUseCase {
export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,

View File

@@ -11,6 +11,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
EntityNotFoundError,
BusinessRuleViolationError,
@@ -31,8 +32,10 @@ export interface ApplyForSponsorshipResultDTO {
status: 'pending';
createdAt: Date;
}
export class ApplyForSponsorshipUseCase {
export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO>
{
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,

View File

@@ -11,6 +11,7 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface ApplyPenaltyCommand {
raceId: string;
@@ -23,7 +24,8 @@ export interface ApplyPenaltyCommand {
notes?: string;
}
export class ApplyPenaltyUseCase {
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository,

View File

@@ -4,10 +4,12 @@ import type {
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '../../domain/entities/Team';
} from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export class ApproveTeamJoinRequestUseCase {
export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}

View File

@@ -1,4 +1,5 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case: CancelRaceUseCase
@@ -13,7 +14,8 @@ export interface CancelRaceCommandDTO {
raceId: string;
}
export class CancelRaceUseCase {
export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
) {}

View File

@@ -4,6 +4,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
@@ -47,7 +48,8 @@ export interface CreateLeagueWithSeasonAndScoringResultDTO {
scoringPresetName?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase {
export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,

View File

@@ -1,11 +1,11 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { Team } from '../../domain/entities/Team';
import type {
Team,
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/entities/Team';
} from '../../domain/types/TeamMembership';
import type {
CreateTeamCommandDTO,
CreateTeamResultDTO,

View File

@@ -5,12 +5,15 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { IAllLeaguesWithCapacityAndScoringPresenter, LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityAndScoringUseCase {
export class GetAllLeaguesWithCapacityAndScoringUseCase
implements AsyncUseCase<void, void>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,

View File

@@ -1,12 +1,15 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IAllLeaguesWithCapacityPresenter } from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityUseCase {
export class GetAllLeaguesWithCapacityUseCase
implements AsyncUseCase<void, void>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,

View File

@@ -6,8 +6,10 @@ import type {
AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export class GetAllRacesPageDataUseCase {
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,

View File

@@ -1,12 +1,14 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IAllTeamsPresenter } from '../presenters/IAllTeamsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase {
export class GetAllTeamsUseCase
implements AsyncUseCase<void, void> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,

View File

@@ -5,9 +5,10 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
@@ -33,7 +34,8 @@ export interface GetDashboardOverviewParams {
driverId: string;
}
export class GetDashboardOverviewUseCase {
export class GetDashboardOverviewUseCase
implements AsyncUseCase<GetDashboardOverviewParams, void> {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly raceRepository: IRaceRepository,
@@ -44,7 +46,7 @@ export class GetDashboardOverviewUseCase {
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly feedRepository: IFeedRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
public readonly presenter: IDashboardOverviewPresenter,
) {}

View File

@@ -1,12 +1,14 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverTeamPresenter } from '../presenters/IDriverTeamPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase {
export class GetDriverTeamUseCase
implements AsyncUseCase<string, boolean> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,

View File

@@ -1,19 +1,21 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IDriversLeaderboardPresenter } from '../presenters/IDriversLeaderboardPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriversLeaderboardUseCase {
export class GetDriversLeaderboardUseCase
implements AsyncUseCase<void, void> {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
public readonly presenter: IDriversLeaderboardPresenter,
) {}

View File

@@ -11,6 +11,7 @@ import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISe
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
@@ -38,7 +39,8 @@ export interface GetEntitySponsorshipPricingResultDTO {
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingUseCase {
export class GetEntitySponsorshipPricingUseCase
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, void> {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,

View File

@@ -3,6 +3,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueDriverSeasonStatsPresenter } from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
@@ -16,7 +17,8 @@ export interface GetLeagueDriverSeasonStatsUseCaseParams {
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueDriverSeasonStatsUseCase {
export class GetLeagueDriverSeasonStatsUseCase
implements AsyncUseCase<GetLeagueDriverSeasonStatsUseCaseParams, void> {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,

View File

@@ -3,13 +3,16 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase {
export class GetLeagueFullConfigUseCase
implements AsyncUseCase<{ leagueId: string }, void>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,

View File

@@ -4,12 +4,14 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { ILeagueScoringConfigPresenter, LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving a league's scoring configuration for its active season.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueScoringConfigUseCase {
export class GetLeagueScoringConfigUseCase
implements AsyncUseCase<{ leagueId: string }, void> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,

View File

@@ -1,5 +1,6 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueStandingsPresenter } from '../presenters/ILeagueStandingsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
@@ -9,7 +10,8 @@ export interface GetLeagueStandingsUseCaseParams {
* Use Case for retrieving league standings.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase {
export class GetLeagueStandingsUseCase
implements AsyncUseCase<GetLeagueStandingsUseCaseParams, void> {
constructor(
private readonly standingRepository: IStandingRepository,
public readonly presenter: ILeagueStandingsPresenter,

View File

@@ -8,6 +8,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
@@ -20,7 +21,8 @@ export interface GetLeagueStatsUseCaseParams {
/**
* Use Case for retrieving league statistics including average SOF across completed races.
*/
export class GetLeagueStatsUseCase {
export class GetLeagueStatsUseCase
implements AsyncUseCase<GetLeagueStatsUseCaseParams, void> {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(

View File

@@ -1,7 +1,7 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type {
IProfileOverviewPresenter,
@@ -44,7 +44,7 @@ export class GetProfileOverviewUseCase {
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
public readonly presenter: IProfileOverviewPresenter,

View File

@@ -5,7 +5,7 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
@@ -39,7 +39,7 @@ export class GetRaceDetailUseCase {
private readonly resultRepository: IResultRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
public readonly presenter: IRaceDetailPresenter,
) {}

View File

@@ -1,6 +1,6 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamJoinRequestsPresenter } from '../presenters/ITeamJoinRequestsPresenter';
/**
@@ -11,7 +11,7 @@ export class GetTeamJoinRequestsUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
public readonly presenter: ITeamJoinRequestsPresenter,
) {}

View File

@@ -1,6 +1,6 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageService } from '../../domain/services/IImageService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamMembersPresenter } from '../presenters/ITeamMembersPresenter';
/**
@@ -11,7 +11,7 @@ export class GetTeamMembersUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageService,
private readonly imageService: IImageServicePort,
public readonly presenter: ITeamMembersPresenter,
) {}

View File

@@ -3,6 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
BusinessRuleViolationError,
EntityNotFoundError,
@@ -26,8 +27,10 @@ export interface ImportRaceResultsParams {
raceId: string;
results: ImportRaceResultDTO[];
}
export class ImportRaceResultsUseCase {
export class ImportRaceResultsUseCase
implements AsyncUseCase<ImportRaceResultsParams, void>
{
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,

View File

@@ -1,15 +1,16 @@
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type {
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
LeagueMembership,
MembershipRole,
MembershipStatus,
type MembershipRole,
type MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export class JoinLeagueUseCase {
export class JoinLeagueUseCase implements AsyncUseCase<JoinLeagueCommandDTO, LeagueMembership> {
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
/**
@@ -27,13 +28,12 @@ export class JoinLeagueUseCase {
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const membership: LeagueMembership = {
const membership = LeagueMembership.create({
leagueId,
driverId,
role: 'member' as MembershipRole,
status: 'active' as MembershipStatus,
joinedAt: new Date(),
};
});
return this.membershipRepository.saveMembership(membership);
}

View File

@@ -4,14 +4,15 @@ import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/entities/Team';
} from '../../domain/types/TeamMembership';
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
BusinessRuleViolationError,
EntityNotFoundError,
} from '../errors/RacingApplicationError';
export class JoinTeamUseCase {
export class JoinTeamUseCase implements AsyncUseCase<JoinTeamCommandDTO, void> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,

View File

@@ -5,8 +5,8 @@ import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IR
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig';
import type { SessionType } from '@gridpilot/racing/domain/types/SessionType';
import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator';

View File

@@ -1,13 +1,16 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import type { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import {
BusinessRuleViolationError,
PermissionDeniedError,
} from '../errors/RacingApplicationError';
export class RegisterForRaceUseCase {
export class RegisterForRaceUseCase
implements AsyncUseCase<RegisterForRaceCommandDTO, void>
{
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
@@ -32,11 +35,10 @@ export class RegisterForRaceUseCase {
throw new PermissionDeniedError('NOT_ACTIVE_MEMBER', 'Must be an active league member to register for races');
}
const registration: RaceRegistration = {
const registration = RaceRegistration.create({
raceId,
driverId,
registeredAt: new Date(),
};
});
await this.registrationRepository.register(registration);
}

View File

@@ -8,7 +8,7 @@
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';
import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles';
export interface RequestProtestDefenseCommand {
protestId: string;

View File

@@ -1,6 +1,6 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Team } from '../../domain/entities/Team';
import { Team } from '../../domain/entities/Team';
import type { UpdateTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class UpdateTeamUseCase {

View File

@@ -1,17 +1,17 @@
/**
* Domain Entity: Car
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
*
*
* Represents a racing car/vehicle in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
export class Car {
export class Car implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly shortName: string;

View File

@@ -1,4 +1,4 @@
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ParticipantRef } from '../types/ParticipantRef';
export class ChampionshipStanding {
readonly seasonId: string;

View File

@@ -4,10 +4,11 @@
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Driver {
import type { IEntity } from '@gridpilot/shared/domain';
export class Driver implements IEntity<string> {
readonly id: string;
readonly iracingId: string;
readonly name: string;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: DriverLivery
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a driver's custom livery for a specific car.
* Includes user-placed decals and league-specific overrides.
@@ -31,7 +32,7 @@ export interface DriverLiveryProps {
validatedAt?: Date;
}
export class DriverLivery {
export class DriverLivery implements IEntity<string> {
readonly id: string;
readonly driverId: string;
readonly gameId: string;

View File

@@ -1,6 +1,7 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Game {
import type { IEntity } from '@gridpilot/shared/domain';
export class Game implements IEntity<string> {
readonly id: string;
readonly name: string;

View File

@@ -4,8 +4,9 @@
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
/**
* Stewarding decision mode for protests
@@ -78,8 +79,8 @@ export interface LeagueSocialLinks {
youtubeUrl?: string;
websiteUrl?: string;
}
export class League {
export class League implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly description: string;

View File

@@ -1,19 +1,75 @@
/**
* Domain Entity: LeagueMembership and JoinRequest
*
* Extracted from racing-application memberships module so that
* membership-related types live in the racing-domain package.
* Represents a driver's membership in a league and join requests.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type MembershipStatus = 'active' | 'pending' | 'none';
export interface LeagueMembership {
export interface LeagueMembershipProps {
id?: string;
leagueId: string;
driverId: string;
role: MembershipRole;
status: MembershipStatus;
joinedAt: Date;
status?: MembershipStatus;
joinedAt?: Date;
}
export class LeagueMembership implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly role: MembershipRole;
readonly status: MembershipStatus;
readonly joinedAt: Date;
private constructor(props: Required<LeagueMembershipProps>) {
this.id = props.id;
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.role = props.role;
this.status = props.status;
this.joinedAt = props.joinedAt;
}
static create(props: LeagueMembershipProps): LeagueMembership {
this.validate(props);
const id =
props.id && props.id.trim().length > 0
? props.id
: `${props.leagueId}:${props.driverId}`;
const status = props.status ?? 'pending';
const joinedAt = props.joinedAt ?? new Date();
return new LeagueMembership({
id,
leagueId: props.leagueId,
driverId: props.driverId,
role: props.role,
status,
joinedAt,
});
}
private static validate(props: LeagueMembershipProps): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
if (!props.role) {
throw new RacingDomainValidationError('Membership role is required');
}
}
}
export interface JoinRequest {

View File

@@ -1,4 +1,4 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
export interface LeagueScoringConfig {
id: string;

View File

@@ -4,8 +4,9 @@
* Represents a league's financial wallet.
* Aggregate root for managing league finances and transactions.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
import type { Transaction } from './Transaction';
@@ -18,7 +19,7 @@ export interface LeagueWalletProps {
createdAt: Date;
}
export class LeagueWallet {
export class LeagueWallet implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly balance: Money;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: LiveryTemplate
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents an admin-defined livery template for a specific car.
* Contains base image and sponsor decal placements.
@@ -21,7 +22,7 @@ export interface LiveryTemplateProps {
updatedAt?: Date;
}
export class LiveryTemplate {
export class LiveryTemplate implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly seasonId: string;

View File

@@ -4,8 +4,9 @@
* Represents a penalty applied to a driver for an incident during a race.
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
@@ -45,7 +46,7 @@ export interface PenaltyProps {
notes?: string;
}
export class Penalty {
export class Penalty implements IEntity<string> {
private constructor(private readonly props: PenaltyProps) {}
static create(props: PenaltyProps): Penalty {

View File

@@ -3,8 +3,9 @@
*
* Represents a prize awarded to a driver for a specific position in a season.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
@@ -23,7 +24,7 @@ export interface PrizeProps {
description?: string;
}
export class Prize {
export class Prize implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly position: number;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: Protest
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*
@@ -66,7 +67,7 @@ export interface ProtestProps {
defenseRequestedBy?: string;
}
export class Protest {
export class Protest implements IEntity<string> {
private constructor(private readonly props: ProtestProps) {}
static create(props: ProtestProps): Protest {

View File

@@ -4,13 +4,14 @@
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race {
export class Race implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly scheduledAt: Date;

View File

@@ -1,12 +1,57 @@
/**
* Domain Entity: RaceRegistration
*
* Extracted from racing-application registrations module so that
* registration-related types live in the racing-domain package.
* Represents a registration of a driver for a specific race.
*/
export interface RaceRegistration {
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface RaceRegistrationProps {
id?: string;
raceId: string;
driverId: string;
registeredAt: Date;
registeredAt?: Date;
}
export class RaceRegistration implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly registeredAt: Date;
private constructor(props: Required<RaceRegistrationProps>) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.registeredAt = props.registeredAt;
}
static create(props: RaceRegistrationProps): RaceRegistration {
this.validate(props);
const id =
props.id && props.id.trim().length > 0
? props.id
: `${props.raceId}:${props.driverId}`;
const registeredAt = props.registeredAt ?? new Date();
return new RaceRegistration({
id,
raceId: props.raceId,
driverId: props.driverId,
registeredAt,
});
}
private static validate(props: RaceRegistrationProps): void {
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
}
}

View File

@@ -4,10 +4,11 @@
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Result {
export class Result implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;

View File

@@ -1,8 +1,9 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Season {
import type { IEntity } from '@gridpilot/shared/domain';
export class Season implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly gameId: string;

View File

@@ -4,8 +4,9 @@
* Represents a sponsorship relationship between a Sponsor and a Season.
* Aggregate root for managing sponsorship slots and pricing.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
@@ -24,7 +25,7 @@ export interface SeasonSponsorshipProps {
description?: string;
}
export class SeasonSponsorship {
export class SeasonSponsorship implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly sponsorId: string;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: Sponsor
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a sponsor that can sponsor leagues/seasons.
* Aggregate root for sponsor information.
@@ -17,7 +18,7 @@ export interface SponsorProps {
createdAt: Date;
}
export class Sponsor {
export class Sponsor implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly contactEmail: string;

View File

@@ -4,8 +4,9 @@
* Represents a sponsorship application from a Sponsor to any sponsorable entity
* (driver, team, race, or league/season). The entity owner must approve/reject.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
import type { SponsorshipTier } from './SeasonSponsorship';
@@ -28,7 +29,7 @@ export interface SponsorshipRequestProps {
rejectionReason?: string;
}
export class SponsorshipRequest {
export class SponsorshipRequest implements IEntity<string> {
readonly id: string;
readonly sponsorId: string;
readonly entityType: SponsorableEntityType;

View File

@@ -1,21 +1,24 @@
/**
* Domain Entity: Standing
*
*
* Represents a championship standing in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Standing {
export class Standing implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly points: number;
readonly wins: number;
readonly position: number;
readonly racesCompleted: number;
private constructor(props: {
id: string;
leagueId: string;
driverId: string;
points: number;
@@ -23,6 +26,7 @@ export class Standing {
position: number;
racesCompleted: number;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.points = props.points;
@@ -35,6 +39,7 @@ export class Standing {
* Factory method to create a new Standing entity
*/
static create(props: {
id?: string;
leagueId: string;
driverId: string;
points?: number;
@@ -44,7 +49,12 @@ export class Standing {
}): Standing {
this.validate(props);
const id = props.id && props.id.trim().length > 0
? props.id
: `${props.leagueId}:${props.driverId}`;
return new Standing({
id,
leagueId: props.leagueId,
driverId: props.driverId,
points: props.points ?? 0,
@@ -58,15 +68,16 @@ export class Standing {
* Domain validation logic
*/
private static validate(props: {
id?: string;
leagueId: string;
driverId: string;
}): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainError('League ID is required');
throw new RacingDomainValidationError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainError('Driver ID is required');
throw new RacingDomainValidationError('Driver ID is required');
}
}
@@ -78,6 +89,7 @@ export class Standing {
const isWin = position === 1;
return new Standing({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points + racePoints,

View File

@@ -1,35 +1,128 @@
/**
* Domain Entities: Team, TeamMembership, TeamJoinRequest
* Domain Entity: Team
*
* Extracted from racing-application teams module so that
* team-related types live in the racing-domain package.
* Represents a racing team in the GridPilot platform.
* Implements the shared IEntity<string> contract and encapsulates
* basic invariants around identity and core properties.
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface Team {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}
export class Team implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly tag: string;
readonly description: string;
readonly ownerId: string;
readonly leagues: string[];
readonly createdAt: Date;
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
private constructor(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}) {
this.id = props.id;
this.name = props.name;
this.tag = props.tag;
this.description = props.description;
this.ownerId = props.ownerId;
this.leagues = props.leagues;
this.createdAt = props.createdAt;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
/**
* Factory method to create a new Team entity.
*/
static create(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt?: Date;
}): Team {
this.validate(props);
return new Team({
id: props.id,
name: props.name,
tag: props.tag,
description: props.description,
ownerId: props.ownerId,
leagues: [...props.leagues],
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Create a copy with updated properties.
*/
update(props: Partial<{
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}>): Team {
const next: Team = new Team({
id: this.id,
name: props.name ?? this.name,
tag: props.tag ?? this.tag,
description: props.description ?? this.description,
ownerId: props.ownerId ?? this.ownerId,
leagues: props.leagues ? [...props.leagues] : [...this.leagues],
createdAt: this.createdAt,
});
// Re-validate updated aggregate
Team.validate({
id: next.id,
name: next.name,
tag: next.tag,
description: next.description,
ownerId: next.ownerId,
leagues: next.leagues,
});
return next;
}
/**
* Domain validation logic for core invariants.
*/
private static validate(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Team ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Team name is required');
}
if (!props.tag || props.tag.trim().length === 0) {
throw new RacingDomainValidationError('Team tag is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new RacingDomainValidationError('Team owner ID is required');
}
if (!Array.isArray(props.leagues)) {
throw new RacingDomainValidationError('Team leagues must be an array');
}
}
}

View File

@@ -4,13 +4,14 @@
* Represents a racing track/circuit in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export class Track {
export class Track implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly shortName: string;

View File

@@ -3,10 +3,11 @@
*
* Represents a financial transaction in the league wallet system.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
import type { IEntity } from '@gridpilot/shared/domain';
export type TransactionType =
| 'sponsorship_payment'
@@ -30,8 +31,8 @@ export interface TransactionProps {
description?: string;
metadata?: Record<string, unknown>;
}
export class Transaction {
export class Transaction implements IEntity<string> {
readonly id: string;
readonly walletId: string;
readonly type: TransactionType;

View File

@@ -1,5 +1,9 @@
export abstract class RacingDomainError extends Error {
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
export abstract class RacingDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
readonly type = 'domain' as const;
readonly context = 'racing-domain';
abstract readonly kind: CommonDomainErrorKind;
constructor(message: string) {
super(message);
@@ -7,7 +11,10 @@ export abstract class RacingDomainError extends Error {
}
}
export class RacingDomainValidationError extends RacingDomainError {
export class RacingDomainValidationError
extends RacingDomainError
implements IDomainError<'validation'>
{
readonly kind = 'validation' as const;
constructor(message: string) {
@@ -15,7 +22,10 @@ export class RacingDomainValidationError extends RacingDomainError {
}
}
export class RacingDomainInvariantError extends RacingDomainError {
export class RacingDomainInvariantError
extends RacingDomainError
implements IDomainError<'invariant'>
{
readonly kind = 'invariant' as const;
constructor(message: string) {

View File

@@ -8,7 +8,7 @@
import type {
TeamMembership,
TeamJoinRequest,
} from '../entities/Team';
} from '../types/TeamMembership';
export interface ITeamMembershipRepository {
/**

View File

@@ -1,5 +1,5 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { ParticipantRef } from '../types/ParticipantRef';
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
import type { ParticipantEventPoints } from './EventScoringService';
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';

View File

@@ -1,4 +1,5 @@
import type { DropScorePolicy } from '../value-objects/DropScorePolicy';
import type { DropScorePolicy } from '../types/DropScorePolicy';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface EventPointsEntry {
eventId: string;
@@ -11,7 +12,16 @@ export interface DropScoreResult {
totalPoints: number;
}
export class DropScoreApplier {
export interface DropScoreInput {
policy: DropScorePolicy;
events: EventPointsEntry[];
}
export class DropScoreApplier implements IDomainCalculationService<DropScoreInput, DropScoreResult> {
calculate(input: DropScoreInput): DropScoreResult {
return this.apply(input.policy, input.events);
}
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
if (policy.strategy === 'none' || events.length === 0) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);

View File

@@ -1,12 +1,13 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { SessionType } from '../value-objects/SessionType';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { SessionType } from '../types/SessionType';
import type { ParticipantRef } from '../types/ParticipantRef';
import type { Result } from '../entities/Result';
import type { Penalty } from '../entities/Penalty';
import type { BonusRule } from '../value-objects/BonusRule';
import type { ChampionshipType } from '../value-objects/ChampionshipType';
import type { BonusRule } from '../types/BonusRule';
import type { ChampionshipType } from '../types/ChampionshipType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface ParticipantEventPoints {
participant: ParticipantRef;
@@ -16,6 +17,14 @@ export interface ParticipantEventPoints {
totalPoints: number;
}
export interface EventScoringInput {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}
function createDriverParticipant(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
@@ -23,14 +32,14 @@ function createDriverParticipant(driverId: string): ParticipantRef {
};
}
export class EventScoringService {
scoreSession(params: {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}): ParticipantEventPoints[] {
export class EventScoringService
implements IDomainCalculationService<EventScoringInput, ParticipantEventPoints[]>
{
calculate(input: EventScoringInput): ParticipantEventPoints[] {
return this.scoreSession(input);
}
scoreSession(params: EventScoringInput): ParticipantEventPoints[] {
const { championship, sessionType, results } = params;
const pointsTable = this.getPointsTableForSession(championship, sessionType);

View File

@@ -1,12 +1,7 @@
/**
* Domain Service Port: IImageService
* Backwards-compat alias for legacy imports.
*
* Thin abstraction used by racing application use cases to obtain image URLs
* for drivers, teams and leagues without depending directly on UI/media layers.
* New code should depend on IImageServicePort from
* packages/racing/application/ports/IImageServicePort.
*/
export interface IImageService {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}
export type { IImageServicePort as IImageService } from '../../application/ports/IImageServicePort';

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from './ScheduleCalculator';
import type { Weekday } from '../value-objects/Weekday';
describe('ScheduleCalculator', () => {
/**
* Tests for ScheduleCalculator have been moved to:
* tests/unit/domain/services/ScheduleCalculator.test.ts
*
* This file is kept as a stub to avoid placing tests under domain/services.
*/
describe('calculateRaceDates', () => {
describe('with empty or invalid input', () => {
it('should return empty array when weekdays is empty', () => {

View File

@@ -1,4 +1,4 @@
import type { Weekday } from '../value-objects/Weekday';
import type { Weekday } from '../types/Weekday';
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';

View File

@@ -3,8 +3,9 @@ import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../value-objects/Weekday';
import { weekdayToIndex } from '../value-objects/Weekday';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
function cloneDate(date: Date): Date {
return new Date(date.getTime());
@@ -173,4 +174,12 @@ export class SeasonScheduleGenerator {
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
}
}
export class SeasonScheduleGeneratorService
implements IDomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
{
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return SeasonScheduleGenerator.generateSlots(schedule);
}
}

View File

@@ -1,10 +1,13 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
/**
* Domain service for determining skill level based on rating.
* This encapsulates the business rule for skill tier classification.
*/
export class SkillLevelService {
export class SkillLevelService implements IDomainService {
readonly serviceName = 'SkillLevelService';
/**
* Map driver rating to skill level band.
* Business rule: iRating thresholds determine skill tiers.

View File

@@ -1,6 +1,8 @@
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
/**
* Domain Service: StrengthOfFieldCalculator
*
*
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
* SOF is the average rating of all participants in a race.
*/
@@ -21,7 +23,9 @@ export interface StrengthOfFieldCalculator {
/**
* Default implementation using simple average
*/
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
export class AverageStrengthOfFieldCalculator
implements StrengthOfFieldCalculator, IDomainCalculationService<DriverRating[], number | null>
{
calculate(driverRatings: DriverRating[]): number | null {
if (driverRatings.length === 0) {
return null;

View File

@@ -0,0 +1,21 @@
import type { ChampionshipType } from '../types/ChampionshipType';
import type { SessionType } from '../types/SessionType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { BonusRule } from '../types/BonusRule';
import type { DropScorePolicy } from '../types/DropScorePolicy';
/**
* Domain Type: ChampionshipConfig
*
* Pure configuration shape for a championship's scoring model.
* This is not a value object and intentionally lives under domain/types.
*/
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -1,6 +1,6 @@
/**
* Domain Value Object: LeagueRoles
*
* Domain Types/Utilities: LeagueRoles
*
* Utility functions for working with league membership roles.
*/

View File

@@ -0,0 +1,25 @@
/**
* Domain Types: TeamRole, TeamMembershipStatus, TeamMembership, TeamJoinRequest
*
* These are pure domain data shapes (no behavior) used across repositories
* and application DTOs. They are not entities or value objects.
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
}

View File

@@ -1,15 +0,0 @@
import type { ChampionshipType } from './ChampionshipType';
import type { SessionType } from './SessionType';
import { PointsTable } from './PointsTable';
import type { BonusRule } from './BonusRule';
import type { DropScorePolicy } from './DropScorePolicy';
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -1,10 +1,12 @@
/**
* Domain Value Object: GameConstraints
*
*
* Represents game-specific constraints for leagues.
* Different sim racing games have different maximum grid sizes.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
export interface GameConstraintsData {
readonly maxDrivers: number;
readonly maxTeams: number;
@@ -14,6 +16,11 @@ export interface GameConstraintsData {
readonly supportsMultiClass: boolean;
}
export interface GameConstraintsProps {
gameId: string;
constraints: GameConstraintsData;
}
/**
* Game-specific constraints for popular sim racing games
*/
@@ -69,7 +76,7 @@ const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
},
};
export class GameConstraints {
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
@@ -78,6 +85,17 @@ export class GameConstraints {
this.constraints = constraints;
}
get props(): GameConstraintsProps {
return {
gameId: this.gameId,
constraints: this.constraints,
};
}
equals(other: IValueObject<GameConstraintsProps>): boolean {
return this.props.gameId === other.props.gameId;
}
/**
* Get constraints for a specific game
*/

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueDescriptionValidationResult {
valid: boolean;
@@ -17,7 +18,11 @@ export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
recommendedMinLength: 50,
} as const;
export class LeagueDescription {
export interface LeagueDescriptionProps {
value: string;
}
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
readonly value: string;
private constructor(value: string) {
@@ -70,6 +75,10 @@ export class LeagueDescription {
return new LeagueDescription(value.trim());
}
get props(): LeagueDescriptionProps {
return { value: this.value };
}
/**
* Try to create a LeagueDescription, returning null if invalid
*/
@@ -84,8 +93,8 @@ export class LeagueDescription {
toString(): string {
return this.value;
}
equals(other: LeagueDescription): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueNameValidationResult {
valid: boolean;
@@ -22,7 +23,11 @@ export const LEAGUE_NAME_CONSTRAINTS = {
],
} as const;
export class LeagueName {
export interface LeagueNameProps {
value: string;
}
export class LeagueName implements IValueObject<LeagueNameProps> {
readonly value: string;
private constructor(value: string) {
@@ -83,6 +88,10 @@ export class LeagueName {
return new LeagueName(value.trim());
}
get props(): LeagueNameProps {
return { value: this.value };
}
/**
* Try to create a LeagueName, returning null if invalid
*/
@@ -97,8 +106,8 @@ export class LeagueName {
toString(): string {
return this.value;
}
equals(other: LeagueName): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueNameProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -1,6 +1,11 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class LeagueTimezone {
export interface LeagueTimezoneProps {
id: string;
}
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
private readonly id: string;
constructor(id: string) {
@@ -13,4 +18,12 @@ export class LeagueTimezone {
getId(): string {
return this.id;
}
get props(): LeagueTimezoneProps {
return { id: this.id };
}
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
return this.props.id === other.props.id;
}
}

View File

@@ -1,13 +1,16 @@
/**
* Domain Value Object: LeagueVisibility
*
*
* Represents the visibility and ranking status of a league.
*
*
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
* Requires minimum 10 players to ensure competitive integrity.
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
* Can have any number of players.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type LeagueVisibilityType = 'ranked' | 'unranked';
@@ -33,7 +36,11 @@ const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConst
},
};
export class LeagueVisibility {
export interface LeagueVisibilityProps {
type: LeagueVisibilityType;
}
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
@@ -112,6 +119,10 @@ export class LeagueVisibility {
return this.type;
}
get props(): LeagueVisibilityProps {
return { type: this.type };
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
@@ -119,8 +130,8 @@ export class LeagueVisibility {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: LeagueVisibility): boolean {
return this.type === other.type;
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
return this.props.type === other.props.type;
}
}

View File

@@ -2,6 +2,9 @@
* Value Object: LiveryDecal
* Represents a decal/logo placed on a livery
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type DecalType = 'sponsor' | 'user';
@@ -16,8 +19,8 @@ export interface LiveryDecalProps {
zIndex: number;
type: DecalType;
}
export class LiveryDecal {
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
readonly id: string;
readonly imageUrl: string;
readonly x: number;
@@ -138,6 +141,20 @@ export class LiveryDecal {
return `rotate(${this.rotation}deg)`;
}
get props(): LiveryDecalProps {
return {
id: this.id,
imageUrl: this.imageUrl,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
zIndex: this.zIndex,
type: this.type,
};
}
/**
* Check if this decal overlaps with another
*/
@@ -146,7 +163,7 @@ export class LiveryDecal {
const thisBottom = this.y + this.height;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
return !(
thisRight <= other.x ||
this.x >= otherRight ||
@@ -154,4 +171,20 @@ export class LiveryDecal {
this.y >= otherBottom
);
}
equals(other: IValueObject<LiveryDecalProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.id === b.id &&
a.imageUrl === b.imageUrl &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height &&
a.rotation === b.rotation &&
a.zIndex === b.zIndex &&
a.type === b.type
);
}
}

View File

@@ -2,10 +2,11 @@
* Value Object: MembershipFee
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
@@ -14,7 +15,7 @@ export interface MembershipFeeProps {
amount: Money;
}
export class MembershipFee {
export class MembershipFee implements IValueObject<MembershipFeeProps> {
readonly type: MembershipFeeType;
readonly amount: Money;
@@ -53,6 +54,13 @@ export class MembershipFee {
return this.amount.calculateNetAmount();
}
get props(): MembershipFeeProps {
return {
type: this.type,
amount: this.amount,
};
}
/**
* Check if this is a recurring fee
*/
@@ -60,6 +68,12 @@ export class MembershipFee {
return this.type === 'monthly';
}
equals(other: IValueObject<MembershipFeeProps>): boolean {
const a = this.props;
const b = other.props;
return a.type === b.type && a.amount.equals(b.amount);
}
/**
* Get display name for fee type
*/

View File

@@ -2,12 +2,18 @@
* Value Object: Money
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type Currency = 'USD' | 'EUR' | 'GBP';
export class Money {
export interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money implements IValueObject<MoneyProps> {
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
readonly amount: number;
@@ -78,11 +84,20 @@ export class Money {
return this.amount > other.amount;
}
get props(): MoneyProps {
return {
amount: this.amount,
currency: this.currency,
};
}
/**
* Check if this money equals another
*/
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
equals(other: IValueObject<MoneyProps>): boolean {
const a = this.props;
const b = other.props;
return a.amount === b.amount && a.currency === b.currency;
}
/**

View File

@@ -1,6 +1,12 @@
import type { Weekday } from './Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export class MonthlyRecurrencePattern {
export interface MonthlyRecurrencePatternProps {
ordinal: 1 | 2 | 3 | 4;
weekday: Weekday;
}
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
@@ -8,4 +14,17 @@ export class MonthlyRecurrencePattern {
this.ordinal = ordinal;
this.weekday = weekday;
}
get props(): MonthlyRecurrencePatternProps {
return {
ordinal: this.ordinal,
weekday: this.weekday,
};
}
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
const a = this.props;
const b = other.props;
return a.ordinal === b.ordinal && a.weekday === b.weekday;
}
}

View File

@@ -1,4 +1,10 @@
export class PointsTable {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PointsTableProps {
pointsByPosition: Map<number, number>;
}
export class PointsTable implements IValueObject<PointsTableProps> {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
@@ -18,4 +24,23 @@ export class PointsTable {
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
get props(): PointsTableProps {
return {
pointsByPosition: new Map(this.pointsByPosition),
};
}
equals(other: IValueObject<PointsTableProps>): boolean {
const a = this.props.pointsByPosition;
const b = other.props.pointsByPosition;
if (a.size !== b.size) return false;
for (const [key, value] of a.entries()) {
if (b.get(key) !== value) {
return false;
}
}
return true;
}
}

View File

@@ -1,6 +1,12 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class RaceTimeOfDay {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface RaceTimeOfDayProps {
hour: number;
minute: number;
}
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
readonly hour: number;
readonly minute: number;
@@ -21,16 +27,29 @@ export class RaceTimeOfDay {
if (!match) {
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
get props(): RaceTimeOfDayProps {
return {
hour: this.hour,
minute: this.minute,
};
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
const a = this.props;
const b = other.props;
return a.hour === b.hour && a.minute === b.minute;
}
}

View File

@@ -1,56 +0,0 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
export type WeeklyRecurrence = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrence = {
kind: 'everyNWeeks';
intervalWeeks: number;
weekdays: WeekdaySet;
};
export type MonthlyNthWeekdayRecurrence = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrence
| EveryNWeeksRecurrence
| MonthlyNthWeekdayRecurrence;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
throw new RacingDomainValidationError(
'everyNWeeks intervalWeeks must be an integer between 1 and 12',
);
}
return {
kind: 'everyNWeeks',
intervalWeeks,
weekdays,
};
}
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern,
};
}
}

View File

@@ -1,8 +1,15 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class ScheduledRaceSlot {
export interface ScheduledRaceSlotProps {
roundNumber: number;
scheduledAt: Date;
timezone: LeagueTimezone;
}
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
@@ -19,4 +26,22 @@ export class ScheduledRaceSlot {
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
get props(): ScheduledRaceSlotProps {
return {
roundNumber: this.roundNumber,
scheduledAt: this.scheduledAt,
timezone: this.timezone,
};
}
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.roundNumber === b.roundNumber &&
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
a.timezone.equals(b.timezone)
);
}
}

Some files were not shown because too many files have changed in this diff Show More