diff --git a/apps/companion/main/preload.ts b/apps/companion/main/preload.ts index 296136738..1d663fd68 100644 --- a/apps/companion/main/preload.ts +++ b/apps/companion/main/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig'; import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState'; export interface AuthStatusEvent { diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index d6f4f81f9..1af985f60 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -198,7 +198,7 @@ function createDefaultForm(): LeagueConfigFormModel { sessionCount: 2, roundsPlanned: 8, // Default to Saturday races, weekly, starting next week - weekdays: ['Sat'] as import('@gridpilot/racing/domain/value-objects/Weekday').Weekday[], + weekdays: ['Sat'] as import('@gridpilot/racing/domain/types/Weekday').Weekday[], recurrenceStrategy: 'weekly' as const, raceStartTime: '20:00', timezoneId: 'UTC', diff --git a/apps/website/components/leagues/LeagueTimingsSection.tsx b/apps/website/components/leagues/LeagueTimingsSection.tsx index 6b60c635b..c786ea35a 100644 --- a/apps/website/components/leagues/LeagueTimingsSection.tsx +++ b/apps/website/components/leagues/LeagueTimingsSection.tsx @@ -23,7 +23,7 @@ import type { LeagueConfigFormModel, LeagueSchedulePreviewDTO, } from '@gridpilot/racing/application'; -import type { Weekday } from '@gridpilot/racing/domain/value-objects/Weekday'; +import type { Weekday } from '@gridpilot/racing/domain/types/Weekday'; import Input from '@/components/ui/Input'; import RangeField from '@/components/ui/RangeField'; diff --git a/apps/website/lib/presenters/LeagueFullConfigPresenter.ts b/apps/website/lib/presenters/LeagueFullConfigPresenter.ts index 8eb78367b..506f57f92 100644 --- a/apps/website/lib/presenters/LeagueFullConfigPresenter.ts +++ b/apps/website/lib/presenters/LeagueFullConfigPresenter.ts @@ -1,4 +1,4 @@ -import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy'; +import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy'; import type { ILeagueFullConfigPresenter, LeagueFullConfigData, diff --git a/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts b/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts index bc1b25cea..ca9a3208b 100644 --- a/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts +++ b/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts @@ -1,5 +1,5 @@ -import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; -import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; +import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig'; +import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule'; import type { ILeagueScoringConfigPresenter, LeagueScoringConfigData, diff --git a/packages/analytics/application/use-cases/GetEntityAnalyticsQuery.ts b/packages/analytics/application/use-cases/GetEntityAnalyticsQuery.ts index 10ffb6764..cda25cccd 100644 --- a/packages/analytics/application/use-cases/GetEntityAnalyticsQuery.ts +++ b/packages/analytics/application/use-cases/GetEntityAnalyticsQuery.ts @@ -1,15 +1,16 @@ /** * Query: GetEntityAnalyticsQuery - * + * * Retrieves analytics data for an entity (league, driver, team, race). * Returns metrics formatted for display to sponsors and admins. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository'; -import type { EntityType } from '../../domain/entities/PageView'; -import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot'; +import type { EntityType } from '../../domain/types/PageView'; +import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot'; export interface GetEntityAnalyticsInput { entityType: EntityType; @@ -41,7 +42,8 @@ export interface EntityAnalyticsOutput { }; } -export class GetEntityAnalyticsQuery { +export class GetEntityAnalyticsQuery + implements AsyncUseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly engagementRepository: IEngagementRepository, diff --git a/packages/analytics/application/use-cases/RecordEngagementUseCase.ts b/packages/analytics/application/use-cases/RecordEngagementUseCase.ts index a9047e002..d03e6c1f9 100644 --- a/packages/analytics/application/use-cases/RecordEngagementUseCase.ts +++ b/packages/analytics/application/use-cases/RecordEngagementUseCase.ts @@ -1,9 +1,10 @@ /** * Use Case: RecordEngagementUseCase - * + * * Records an engagement event when a visitor interacts with an entity. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; @@ -22,7 +23,8 @@ export interface RecordEngagementOutput { engagementWeight: number; } -export class RecordEngagementUseCase { +export class RecordEngagementUseCase + implements AsyncUseCase { constructor(private readonly engagementRepository: IEngagementRepository) {} async execute(input: RecordEngagementInput): Promise { diff --git a/packages/analytics/application/use-cases/RecordPageViewUseCase.ts b/packages/analytics/application/use-cases/RecordPageViewUseCase.ts index 165ba30c7..7de0abcde 100644 --- a/packages/analytics/application/use-cases/RecordPageViewUseCase.ts +++ b/packages/analytics/application/use-cases/RecordPageViewUseCase.ts @@ -1,10 +1,12 @@ /** * Use Case: RecordPageViewUseCase - * + * * Records a page view event when a visitor accesses an entity page. */ - -import { PageView, type EntityType, type VisitorType } from '../../domain/entities/PageView'; + +import type { AsyncUseCase } from '@gridpilot/shared/application'; +import { PageView } from '../../domain/entities/PageView'; +import type { EntityType, VisitorType } from '../../domain/types/PageView'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; export interface RecordPageViewInput { @@ -22,7 +24,8 @@ export interface RecordPageViewOutput { pageViewId: string; } -export class RecordPageViewUseCase { +export class RecordPageViewUseCase + implements AsyncUseCase { constructor(private readonly pageViewRepository: IPageViewRepository) {} async execute(input: RecordPageViewInput): Promise { diff --git a/packages/analytics/domain/entities/AnalyticsSnapshot.ts b/packages/analytics/domain/entities/AnalyticsSnapshot.ts index edcba01a1..d4e95af5e 100644 --- a/packages/analytics/domain/entities/AnalyticsSnapshot.ts +++ b/packages/analytics/domain/entities/AnalyticsSnapshot.ts @@ -1,52 +1,34 @@ /** * Domain Entity: AnalyticsSnapshot - * + * * Aggregated analytics data for a specific entity over a time period. * Pre-calculated metrics for sponsor dashboard and entity analytics. */ -export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly'; -export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; +import type { IEntity } from '@gridpilot/shared/domain'; +import type { + AnalyticsSnapshotProps, + AnalyticsMetrics, + SnapshotEntityType, + SnapshotPeriod, +} from '../types/AnalyticsSnapshot'; +import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId'; -export interface AnalyticsMetrics { - pageViews: number; - uniqueVisitors: number; - avgSessionDuration: number; - bounceRate: number; - engagementScore: number; - sponsorClicks: number; - sponsorUrlClicks: number; - socialShares: number; - leagueJoins: number; - raceRegistrations: number; - exposureValue: number; -} - -export interface AnalyticsSnapshotProps { - id: string; - entityType: SnapshotEntityType; - entityId: string; - period: SnapshotPeriod; - startDate: Date; - endDate: Date; - metrics: AnalyticsMetrics; - createdAt: Date; -} - -export class AnalyticsSnapshot { +export class AnalyticsSnapshot implements IEntity { readonly id: string; readonly entityType: SnapshotEntityType; - readonly entityId: string; readonly period: SnapshotPeriod; readonly startDate: Date; readonly endDate: Date; readonly metrics: AnalyticsMetrics; readonly createdAt: Date; + private readonly entityIdVo: AnalyticsEntityId; + private constructor(props: AnalyticsSnapshotProps) { this.id = props.id; this.entityType = props.entityType; - this.entityId = props.entityId; + this.entityIdVo = AnalyticsEntityId.create(props.entityId); this.period = props.period; this.startDate = props.startDate; this.endDate = props.endDate; @@ -54,6 +36,10 @@ export class AnalyticsSnapshot { this.createdAt = props.createdAt; } + get entityId(): string { + return this.entityIdVo.value; + } + static create(props: Omit & { createdAt?: Date }): AnalyticsSnapshot { this.validate(props); diff --git a/packages/analytics/domain/entities/EngagementEvent.ts b/packages/analytics/domain/entities/EngagementEvent.ts index a84a7ff4f..9778fec2e 100644 --- a/packages/analytics/domain/entities/EngagementEvent.ts +++ b/packages/analytics/domain/entities/EngagementEvent.ts @@ -1,51 +1,35 @@ /** * Domain Entity: EngagementEvent - * + * * Represents user interactions beyond page views. * Tracks clicks, downloads, sign-ups, and other engagement actions. */ -export type EngagementAction = - | 'click_sponsor_logo' - | 'click_sponsor_url' - | 'download_livery_pack' - | 'join_league' - | 'register_race' - | 'view_standings' - | 'view_schedule' - | 'share_social' - | 'contact_sponsor'; +import type { IEntity } from '@gridpilot/shared/domain'; +import type { + EngagementAction, + EngagementEntityType, + EngagementEventProps, +} from '../types/EngagementEvent'; +import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId'; -export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship'; - -export interface EngagementEventProps { - id: string; - action: EngagementAction; - entityType: EngagementEntityType; - entityId: string; - actorId?: string; - actorType: 'anonymous' | 'driver' | 'sponsor'; - sessionId: string; - metadata?: Record; - timestamp: Date; -} - -export class EngagementEvent { +export class EngagementEvent implements IEntity { readonly id: string; readonly action: EngagementAction; readonly entityType: EngagementEntityType; - readonly entityId: string; readonly actorId?: string; readonly actorType: 'anonymous' | 'driver' | 'sponsor'; readonly sessionId: string; readonly metadata?: Record; readonly timestamp: Date; + private readonly entityIdVo: AnalyticsEntityId; + private constructor(props: EngagementEventProps) { this.id = props.id; this.action = props.action; this.entityType = props.entityType; - this.entityId = props.entityId; + this.entityIdVo = AnalyticsEntityId.create(props.entityId); this.actorId = props.actorId; this.actorType = props.actorType; this.sessionId = props.sessionId; @@ -53,6 +37,10 @@ export class EngagementEvent { this.timestamp = props.timestamp; } + get entityId(): string { + return this.entityIdVo.value; + } + static create(props: Omit & { timestamp?: Date }): EngagementEvent { this.validate(props); diff --git a/packages/analytics/domain/entities/PageView.ts b/packages/analytics/domain/entities/PageView.ts index b3dcd1fa2..2c8d58f58 100644 --- a/packages/analytics/domain/entities/PageView.ts +++ b/packages/analytics/domain/entities/PageView.ts @@ -1,47 +1,37 @@ /** * Domain Entity: PageView - * + * * Represents a single page view event for analytics tracking. * Captures visitor interactions with leagues, drivers, teams, races. */ -export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; -export type VisitorType = 'anonymous' | 'driver' | 'sponsor'; +import type { IEntity } from '@gridpilot/shared/domain'; +import type { EntityType, VisitorType, PageViewProps } from '../types/PageView'; +import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId'; +import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId'; +import { PageViewId } from '../value-objects/PageViewId'; -export interface PageViewProps { - id: string; - entityType: EntityType; - entityId: string; - visitorId?: string; - visitorType: VisitorType; - sessionId: string; - referrer?: string; - userAgent?: string; - country?: string; - timestamp: Date; - durationMs?: number; -} - -export class PageView { - readonly id: string; +export class PageView implements IEntity { readonly entityType: EntityType; - readonly entityId: string; readonly visitorId?: string; readonly visitorType: VisitorType; - readonly sessionId: string; readonly referrer?: string; readonly userAgent?: string; readonly country?: string; readonly timestamp: Date; readonly durationMs?: number; + private readonly idVo: PageViewId; + private readonly entityIdVo: AnalyticsEntityId; + private readonly sessionIdVo: AnalyticsSessionId; + private constructor(props: PageViewProps) { - this.id = props.id; + this.idVo = PageViewId.create(props.id); this.entityType = props.entityType; - this.entityId = props.entityId; + this.entityIdVo = AnalyticsEntityId.create(props.entityId); this.visitorId = props.visitorId; this.visitorType = props.visitorType; - this.sessionId = props.sessionId; + this.sessionIdVo = AnalyticsSessionId.create(props.sessionId); this.referrer = props.referrer; this.userAgent = props.userAgent; this.country = props.country; @@ -49,6 +39,18 @@ export class PageView { this.durationMs = props.durationMs; } + get id(): string { + return this.idVo.value; + } + + get entityId(): string { + return this.entityIdVo.value; + } + + get sessionId(): string { + return this.sessionIdVo.value; + } + static create(props: Omit & { timestamp?: Date }): PageView { this.validate(props); diff --git a/packages/analytics/domain/types/AnalyticsSnapshot.ts b/packages/analytics/domain/types/AnalyticsSnapshot.ts new file mode 100644 index 000000000..74a1e8af6 --- /dev/null +++ b/packages/analytics/domain/types/AnalyticsSnapshot.ts @@ -0,0 +1,35 @@ +/** + * Domain Types: AnalyticsSnapshot + * + * Pure type/config definitions used by the AnalyticsSnapshot entity. + * Kept in domain/types so domain/entities contains only entity classes. + */ + +export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly'; + +export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; + +export interface AnalyticsMetrics { + pageViews: number; + uniqueVisitors: number; + avgSessionDuration: number; + bounceRate: number; + engagementScore: number; + sponsorClicks: number; + sponsorUrlClicks: number; + socialShares: number; + leagueJoins: number; + raceRegistrations: number; + exposureValue: number; +} + +export interface AnalyticsSnapshotProps { + id: string; + entityType: SnapshotEntityType; + entityId: string; + period: SnapshotPeriod; + startDate: Date; + endDate: Date; + metrics: AnalyticsMetrics; + createdAt: Date; +} \ No newline at end of file diff --git a/packages/analytics/domain/types/EngagementEvent.ts b/packages/analytics/domain/types/EngagementEvent.ts new file mode 100644 index 000000000..d979bf9f9 --- /dev/null +++ b/packages/analytics/domain/types/EngagementEvent.ts @@ -0,0 +1,37 @@ +/** + * Domain Types: EngagementEvent + * + * Pure type/config definitions used by the EngagementEvent entity. + * Kept in domain/types so domain/entities contains only entity classes. + */ + +export type EngagementAction = + | 'click_sponsor_logo' + | 'click_sponsor_url' + | 'download_livery_pack' + | 'join_league' + | 'register_race' + | 'view_standings' + | 'view_schedule' + | 'share_social' + | 'contact_sponsor'; + +export type EngagementEntityType = + | 'league' + | 'driver' + | 'team' + | 'race' + | 'sponsor' + | 'sponsorship'; + +export interface EngagementEventProps { + id: string; + action: EngagementAction; + entityType: EngagementEntityType; + entityId: string; + actorId?: string; + actorType: 'anonymous' | 'driver' | 'sponsor'; + sessionId: string; + metadata?: Record; + timestamp: Date; +} \ No newline at end of file diff --git a/packages/analytics/domain/types/PageView.ts b/packages/analytics/domain/types/PageView.ts new file mode 100644 index 000000000..6c92828d8 --- /dev/null +++ b/packages/analytics/domain/types/PageView.ts @@ -0,0 +1,24 @@ +/** + * Domain Types: PageView + * + * Pure type/config definitions used by the PageView entity. + * Kept in domain/types so domain/entities contains only entity classes. + */ + +export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; + +export type VisitorType = 'anonymous' | 'driver' | 'sponsor'; + +export interface PageViewProps { + id: string; + entityType: EntityType; + entityId: string; + visitorId?: string; + visitorType: VisitorType; + sessionId: string; + referrer?: string; + userAgent?: string; + country?: string; + timestamp: Date; + durationMs?: number; +} \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts b/packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts new file mode 100644 index 000000000..449914c86 --- /dev/null +++ b/packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts @@ -0,0 +1,29 @@ +import { AnalyticsEntityId } from './AnalyticsEntityId'; + +describe('AnalyticsEntityId', () => { + it('creates a valid AnalyticsEntityId from a non-empty string', () => { + const id = AnalyticsEntityId.create('entity_123'); + + expect(id.value).toBe('entity_123'); + }); + + it('trims whitespace from the raw value', () => { + const id = AnalyticsEntityId.create(' entity_456 '); + + expect(id.value).toBe('entity_456'); + }); + + it('throws for empty or whitespace-only strings', () => { + expect(() => AnalyticsEntityId.create('')).toThrow(Error); + expect(() => AnalyticsEntityId.create(' ')).toThrow(Error); + }); + + it('compares equality based on underlying value', () => { + const a = AnalyticsEntityId.create('entity_1'); + const b = AnalyticsEntityId.create('entity_1'); + const c = AnalyticsEntityId.create('entity_2'); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/AnalyticsEntityId.ts b/packages/analytics/domain/value-objects/AnalyticsEntityId.ts new file mode 100644 index 000000000..fb6c97d9e --- /dev/null +++ b/packages/analytics/domain/value-objects/AnalyticsEntityId.ts @@ -0,0 +1,37 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface AnalyticsEntityIdProps { + value: string; +} + +/** + * Value Object: AnalyticsEntityId + * + * Represents the ID of an entity (league, driver, team, race, sponsor) + * within the analytics bounded context. + */ +export class AnalyticsEntityId implements IValueObject { + public readonly props: AnalyticsEntityIdProps; + + private constructor(value: string) { + this.props = { value }; + } + + static create(raw: string): AnalyticsEntityId { + const value = raw.trim(); + + if (!value) { + throw new Error('AnalyticsEntityId must be a non-empty string'); + } + + return new AnalyticsEntityId(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } +} \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts b/packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts new file mode 100644 index 000000000..9877684a6 --- /dev/null +++ b/packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts @@ -0,0 +1,29 @@ +import { AnalyticsSessionId } from './AnalyticsSessionId'; + +describe('AnalyticsSessionId', () => { + it('creates a valid AnalyticsSessionId from a non-empty string', () => { + const id = AnalyticsSessionId.create('session_123'); + + expect(id.value).toBe('session_123'); + }); + + it('trims whitespace from the raw value', () => { + const id = AnalyticsSessionId.create(' session_456 '); + + expect(id.value).toBe('session_456'); + }); + + it('throws for empty or whitespace-only strings', () => { + expect(() => AnalyticsSessionId.create('')).toThrow(Error); + expect(() => AnalyticsSessionId.create(' ')).toThrow(Error); + }); + + it('compares equality based on underlying value', () => { + const a = AnalyticsSessionId.create('session_1'); + const b = AnalyticsSessionId.create('session_1'); + const c = AnalyticsSessionId.create('session_2'); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/AnalyticsSessionId.ts b/packages/analytics/domain/value-objects/AnalyticsSessionId.ts new file mode 100644 index 000000000..b1e03fc2d --- /dev/null +++ b/packages/analytics/domain/value-objects/AnalyticsSessionId.ts @@ -0,0 +1,36 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface AnalyticsSessionIdProps { + value: string; +} + +/** + * Value Object: AnalyticsSessionId + * + * Represents an analytics session identifier within the analytics bounded context. + */ +export class AnalyticsSessionId implements IValueObject { + public readonly props: AnalyticsSessionIdProps; + + private constructor(value: string) { + this.props = { value }; + } + + static create(raw: string): AnalyticsSessionId { + const value = raw.trim(); + + if (!value) { + throw new Error('AnalyticsSessionId must be a non-empty string'); + } + + return new AnalyticsSessionId(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } +} \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/PageViewId.test.ts b/packages/analytics/domain/value-objects/PageViewId.test.ts new file mode 100644 index 000000000..65295e5cc --- /dev/null +++ b/packages/analytics/domain/value-objects/PageViewId.test.ts @@ -0,0 +1,29 @@ +import { PageViewId } from './PageViewId'; + +describe('PageViewId', () => { + it('creates a valid PageViewId from a non-empty string', () => { + const id = PageViewId.create('pv_123'); + + expect(id.value).toBe('pv_123'); + }); + + it('trims whitespace from the raw value', () => { + const id = PageViewId.create(' pv_456 '); + + expect(id.value).toBe('pv_456'); + }); + + it('throws for empty or whitespace-only strings', () => { + expect(() => PageViewId.create('')).toThrow(Error); + expect(() => PageViewId.create(' ')).toThrow(Error); + }); + + it('compares equality based on underlying value', () => { + const a = PageViewId.create('pv_1'); + const b = PageViewId.create('pv_1'); + const c = PageViewId.create('pv_2'); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/analytics/domain/value-objects/PageViewId.ts b/packages/analytics/domain/value-objects/PageViewId.ts new file mode 100644 index 000000000..3fe7beaa3 --- /dev/null +++ b/packages/analytics/domain/value-objects/PageViewId.ts @@ -0,0 +1,36 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface PageViewIdProps { + value: string; +} + +/** + * Value Object: PageViewId + * + * Represents the identifier of a PageView within the analytics bounded context. + */ +export class PageViewId implements IValueObject { + public readonly props: PageViewIdProps; + + private constructor(value: string) { + this.props = { value }; + } + + static create(raw: string): PageViewId { + const value = raw.trim(); + + if (!value) { + throw new Error('PageViewId must be a non-empty string'); + } + + return new PageViewId(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } +} \ No newline at end of file diff --git a/packages/automation/application/dto/SessionDTO.ts b/packages/automation/application/dto/SessionDTO.ts index f7d19eb9e..0bee8e3d1 100644 --- a/packages/automation/application/dto/SessionDTO.ts +++ b/packages/automation/application/dto/SessionDTO.ts @@ -1,4 +1,4 @@ -import type { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig'; export interface SessionDTO { sessionId: string; diff --git a/packages/automation/application/ports/AutomationEnginePort.ts b/packages/automation/application/ports/AutomationEnginePort.ts index a19afa3ab..8b3d1eb46 100644 --- a/packages/automation/application/ports/AutomationEnginePort.ts +++ b/packages/automation/application/ports/AutomationEnginePort.ts @@ -1,4 +1,4 @@ -import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig'; import { StepId } from '../../domain/value-objects/StepId'; import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO'; diff --git a/packages/automation/application/services/OverlaySyncService.ts b/packages/automation/application/services/OverlaySyncService.ts index 1183a2a2f..2349f2f4f 100644 --- a/packages/automation/application/services/OverlaySyncService.ts +++ b/packages/automation/application/services/OverlaySyncService.ts @@ -2,6 +2,7 @@ import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncP import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort'; import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter'; import { LoggerPort } from '../ports/LoggerPort'; +import type { IAsyncApplicationService } from '@gridpilot/shared/application'; type ConstructorArgs = { lifecycleEmitter: IAutomationLifecycleEmitter @@ -13,7 +14,9 @@ type ConstructorArgs = { defaultTimeoutMs?: number } -export class OverlaySyncService implements OverlaySyncPort { +export class OverlaySyncService + implements OverlaySyncPort, IAsyncApplicationService +{ private lifecycleEmitter: IAutomationLifecycleEmitter private publisher: AutomationEventPublisherPort private logger: LoggerPort @@ -32,6 +35,10 @@ export class OverlaySyncService implements OverlaySyncPort { this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000 } + async execute(action: OverlayAction): Promise { + return this.startAction(action) + } + async startAction(action: OverlayAction): Promise { const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs const seenEvents: AutomationEvent[] = [] diff --git a/packages/automation/application/use-cases/StartAutomationSessionUseCase.ts b/packages/automation/application/use-cases/StartAutomationSessionUseCase.ts index 3009da3fa..1b2194323 100644 --- a/packages/automation/application/use-cases/StartAutomationSessionUseCase.ts +++ b/packages/automation/application/use-cases/StartAutomationSessionUseCase.ts @@ -1,11 +1,13 @@ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import { AutomationSession } from '../../domain/entities/AutomationSession'; -import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig'; import { AutomationEnginePort } from '../ports/AutomationEnginePort'; import type { IBrowserAutomation } from '../ports/ScreenAutomationPort'; import { SessionRepositoryPort } from '../ports/SessionRepositoryPort'; import type { SessionDTO } from '../dto/SessionDTO'; -export class StartAutomationSessionUseCase { +export class StartAutomationSessionUseCase + implements AsyncUseCase { constructor( private readonly automationEngine: AutomationEnginePort, private readonly browserAutomation: IBrowserAutomation, diff --git a/packages/automation/domain/entities/AutomationSession.ts b/packages/automation/domain/entities/AutomationSession.ts index 5bf1c78f1..fdd811e75 100644 --- a/packages/automation/domain/entities/AutomationSession.ts +++ b/packages/automation/domain/entities/AutomationSession.ts @@ -1,10 +1,11 @@ import { randomUUID } from 'crypto'; +import type { IEntity } from '@gridpilot/shared/domain'; import { StepId } from '../value-objects/StepId'; import { SessionState } from '../value-objects/SessionState'; -import { HostedSessionConfig } from './HostedSessionConfig'; +import type { HostedSessionConfig } from '../types/HostedSessionConfig'; import { AutomationDomainError } from '../errors/AutomationDomainError'; -export class AutomationSession { +export class AutomationSession implements IEntity { private readonly _id: string; private _currentStep: StepId; private _state: SessionState; diff --git a/packages/automation/domain/entities/StepExecution.ts b/packages/automation/domain/entities/StepExecution.ts index 37634391f..98e7b7508 100644 --- a/packages/automation/domain/entities/StepExecution.ts +++ b/packages/automation/domain/entities/StepExecution.ts @@ -1,5 +1,11 @@ -import { StepId } from '../value-objects/StepId'; +import type { StepId } from '../value-objects/StepId'; +/** + * Domain Type: StepExecution + * + * Represents execution metadata for a single automation step. + * This is a pure data shape (DTO-like), not an entity or value object. + */ export interface StepExecution { stepId: StepId; startedAt: Date; diff --git a/packages/automation/domain/errors/AutomationDomainError.ts b/packages/automation/domain/errors/AutomationDomainError.ts index ec7ca7026..590fda351 100644 --- a/packages/automation/domain/errors/AutomationDomainError.ts +++ b/packages/automation/domain/errors/AutomationDomainError.ts @@ -1,8 +1,19 @@ -export class AutomationDomainError extends Error { - readonly name: string = 'AutomationDomainError'; +import type { IDomainError } from '@gridpilot/shared/errors'; - constructor(message: string) { +/** + * Domain Error: AutomationDomainError + * + * Implements the shared IDomainError contract for automation domain failures. + */ +export class AutomationDomainError extends Error implements IDomainError { + readonly name = 'AutomationDomainError'; + readonly type = 'domain' as const; + readonly context = 'automation'; + readonly kind: string; + + constructor(message: string, kind: string = 'validation') { super(message); + this.kind = kind; Object.setPrototypeOf(this, new.target.prototype); } } \ No newline at end of file diff --git a/packages/automation/domain/services/PageStateValidator.ts b/packages/automation/domain/services/PageStateValidator.ts index 3b4378f0c..5f0b334d2 100644 --- a/packages/automation/domain/services/PageStateValidator.ts +++ b/packages/automation/domain/services/PageStateValidator.ts @@ -1,3 +1,4 @@ +import type { IDomainValidationService } from '@gridpilot/shared/domain'; import { Result } from '../../../shared/result/Result'; /** @@ -24,6 +25,12 @@ export interface PageStateValidationResult { unexpectedSelectors?: string[]; } +export interface PageStateValidationInput { + actualState: (selector: string) => boolean; + validation: PageStateValidation; + realMode?: boolean; +} + /** * Domain service for validating page state during wizard navigation. * @@ -32,7 +39,18 @@ export interface PageStateValidationResult { * Clean Architecture: This is pure domain logic with no infrastructure dependencies. * It validates state based on selector presence/absence without knowing HOW to check them. */ -export class PageStateValidator { +export class PageStateValidator + implements + IDomainValidationService +{ + validate(input: PageStateValidationInput): Result { + const { actualState, validation, realMode } = input; + if (typeof realMode === 'boolean') { + return this.validateStateEnhanced(actualState, validation, realMode); + } + return this.validateState(actualState, validation); + } + /** * Validate that the page state matches expected conditions. * diff --git a/packages/automation/domain/services/StepTransitionValidator.ts b/packages/automation/domain/services/StepTransitionValidator.ts index f74dd1af6..6f697f359 100644 --- a/packages/automation/domain/services/StepTransitionValidator.ts +++ b/packages/automation/domain/services/StepTransitionValidator.ts @@ -1,11 +1,21 @@ import { StepId } from '../value-objects/StepId'; import { SessionState } from '../value-objects/SessionState'; +import type { IDomainValidationService } from '@gridpilot/shared/domain'; +import { Result } from '../../../shared/result/Result'; export interface ValidationResult { isValid: boolean; error?: string; } +export interface StepTransitionValidationInput { + currentStep: StepId; + nextStep: StepId; + state: SessionState; +} + +export interface StepTransitionValidationResult extends ValidationResult {} + const STEP_DESCRIPTIONS: Record = { 1: 'Navigate to Hosted Racing page', 2: 'Click Create a Race', @@ -26,7 +36,23 @@ const STEP_DESCRIPTIONS: Record = { 17: 'Track Conditions (STOP - Manual Submit Required)', }; -export class StepTransitionValidator { +export class StepTransitionValidator + implements + IDomainValidationService +{ + validate(input: StepTransitionValidationInput): Result { + try { + const { currentStep, nextStep, state } = input; + const result = StepTransitionValidator.canTransition(currentStep, nextStep, state); + return Result.ok(result); + } catch (error) { + return Result.err( + error instanceof Error + ? error + : new Error(`Step transition validation failed: ${String(error)}`), + ); + } + } static canTransition( currentStep: StepId, nextStep: StepId, diff --git a/packages/automation/domain/entities/HostedSessionConfig.ts b/packages/automation/domain/types/HostedSessionConfig.ts similarity index 83% rename from packages/automation/domain/entities/HostedSessionConfig.ts rename to packages/automation/domain/types/HostedSessionConfig.ts index 0d081ca4d..91a4f247b 100644 --- a/packages/automation/domain/entities/HostedSessionConfig.ts +++ b/packages/automation/domain/types/HostedSessionConfig.ts @@ -1,3 +1,9 @@ +/** + * Domain Type: HostedSessionConfig + * + * Pure configuration shape for an iRacing hosted session. + * This is a DTO-like domain type, not a value object or entity. + */ export interface HostedSessionConfig { sessionName: string; trackId: string; diff --git a/packages/automation/domain/types/ScreenRegion.ts b/packages/automation/domain/types/ScreenRegion.ts new file mode 100644 index 000000000..fd225aa19 --- /dev/null +++ b/packages/automation/domain/types/ScreenRegion.ts @@ -0,0 +1,88 @@ +/** + * Domain Types: ScreenRegion, Point, ElementLocation, LoginDetectionResult + * + * These are pure data shapes and helpers used across automation. + */ + +export interface ScreenRegion { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Represents a point on the screen with x,y coordinates. + */ +export interface Point { + x: number; + y: number; +} + +/** + * Represents the location of a detected UI element on screen. + * Contains the center point, bounding box, and confidence score. + */ +export interface ElementLocation { + center: Point; + bounds: ScreenRegion; + confidence: number; +} + +/** + * Result of login state detection via screen recognition. + */ +export interface LoginDetectionResult { + isLoggedIn: boolean; + confidence: number; + detectedIndicators: string[]; + error?: string; +} + +/** + * Create a ScreenRegion from coordinates. + */ +export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion { + return { x, y, width, height }; +} + +/** + * Create a Point from coordinates. + */ +export function createPoint(x: number, y: number): Point { + return { x, y }; +} + +/** + * Calculate the center point of a ScreenRegion. + */ +export function getRegionCenter(region: ScreenRegion): Point { + return { + x: region.x + Math.floor(region.width / 2), + y: region.y + Math.floor(region.height / 2), + }; +} + +/** + * Check if a point is within a screen region. + */ +export function isPointInRegion(point: Point, region: ScreenRegion): boolean { + return ( + point.x >= region.x && + point.x <= region.x + region.width && + point.y >= region.y && + point.y <= region.y + region.height + ); +} + +/** + * Check if two screen regions overlap. + */ +export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean { + return !( + a.x + a.width < b.x || + b.x + b.width < a.x || + a.y + a.height < b.y || + b.y + b.height < a.y + ); +} \ No newline at end of file diff --git a/packages/automation/domain/value-objects/BrowserAuthenticationState.ts b/packages/automation/domain/value-objects/BrowserAuthenticationState.ts index 530c5cc0e..160040298 100644 --- a/packages/automation/domain/value-objects/BrowserAuthenticationState.ts +++ b/packages/automation/domain/value-objects/BrowserAuthenticationState.ts @@ -1,6 +1,12 @@ import { AuthenticationState } from './AuthenticationState'; +import type { IValueObject } from '@gridpilot/shared/domain'; -export class BrowserAuthenticationState { +export interface BrowserAuthenticationStateProps { + cookiesValid: boolean; + pageAuthenticated: boolean; +} + +export class BrowserAuthenticationState implements IValueObject { private readonly cookiesValid: boolean; private readonly pageAuthenticated: boolean; @@ -36,4 +42,17 @@ export class BrowserAuthenticationState { getPageAuthenticationStatus(): boolean { return this.pageAuthenticated; } + + get props(): BrowserAuthenticationStateProps { + return { + cookiesValid: this.cookiesValid, + pageAuthenticated: this.pageAuthenticated, + }; + } + + equals(other: IValueObject): boolean { + const a = this.props; + const b = other.props; + return a.cookiesValid === b.cookiesValid && a.pageAuthenticated === b.pageAuthenticated; + } } \ No newline at end of file diff --git a/packages/automation/domain/value-objects/CheckoutPrice.ts b/packages/automation/domain/value-objects/CheckoutPrice.ts index 0e3b8edcc..f43db0411 100644 --- a/packages/automation/domain/value-objects/CheckoutPrice.ts +++ b/packages/automation/domain/value-objects/CheckoutPrice.ts @@ -1,4 +1,10 @@ -export class CheckoutPrice { +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface CheckoutPriceProps { + amountUsd: number; +} + +export class CheckoutPrice implements IValueObject { private constructor(private readonly amountUsd: number) { if (amountUsd < 0) { throw new Error('Price cannot be negative'); @@ -54,4 +60,14 @@ export class CheckoutPrice { isZero(): boolean { return this.amountUsd < 0.001; } + + get props(): CheckoutPriceProps { + return { + amountUsd: this.amountUsd, + }; + } + + equals(other: IValueObject): boolean { + return this.props.amountUsd === other.props.amountUsd; + } } \ No newline at end of file diff --git a/packages/automation/domain/value-objects/SessionLifetime.ts b/packages/automation/domain/value-objects/SessionLifetime.ts index c27c02789..9eed5135e 100644 --- a/packages/automation/domain/value-objects/SessionLifetime.ts +++ b/packages/automation/domain/value-objects/SessionLifetime.ts @@ -4,7 +4,14 @@ * Represents the lifetime of an authentication session with expiry tracking. * Handles validation of session expiry dates with a configurable buffer window. */ -export class SessionLifetime { +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface SessionLifetimeProps { + expiry: Date | null; + bufferMinutes: number; +} + +export class SessionLifetime implements IValueObject { private readonly expiry: Date | null; private readonly bufferMinutes: number; @@ -78,8 +85,23 @@ export class SessionLifetime { if (this.expiry === null) { return Infinity; } - + const remaining = this.expiry.getTime() - Date.now(); return Math.max(0, remaining); } + + get props(): SessionLifetimeProps { + return { + expiry: this.expiry, + bufferMinutes: this.bufferMinutes, + }; + } + + equals(other: IValueObject): boolean { + const a = this.props; + const b = other.props; + const aExpiry = a.expiry?.getTime() ?? null; + const bExpiry = b.expiry?.getTime() ?? null; + return aExpiry === bExpiry && a.bufferMinutes === b.bufferMinutes; + } } \ No newline at end of file diff --git a/packages/automation/domain/value-objects/SessionState.ts b/packages/automation/domain/value-objects/SessionState.ts index 300495865..a45e72589 100644 --- a/packages/automation/domain/value-objects/SessionState.ts +++ b/packages/automation/domain/value-objects/SessionState.ts @@ -1,3 +1,5 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + export type SessionStateValue = | 'PENDING' | 'IN_PROGRESS' @@ -30,7 +32,11 @@ const VALID_TRANSITIONS: Record = { CANCELLED: [], }; -export class SessionState { +export interface SessionStateProps { + value: SessionStateValue; +} + +export class SessionState implements IValueObject { private readonly _value: SessionStateValue; private constructor(value: SessionStateValue) { @@ -93,4 +99,12 @@ export class SessionState { this._value === 'CANCELLED' ); } + + get props(): SessionStateProps { + return { value: this._value }; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } } \ No newline at end of file diff --git a/packages/automation/domain/value-objects/StepId.ts b/packages/automation/domain/value-objects/StepId.ts index 9de99fae0..4536950c3 100644 --- a/packages/automation/domain/value-objects/StepId.ts +++ b/packages/automation/domain/value-objects/StepId.ts @@ -1,4 +1,10 @@ -export class StepId { +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface StepIdProps { + value: number; +} + +export class StepId implements IValueObject { private readonly _value: number; private constructor(value: number) { @@ -37,4 +43,12 @@ export class StepId { } return StepId.create(this._value + 1); } + + get props(): StepIdProps { + return { value: this._value }; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } } \ No newline at end of file diff --git a/packages/automation/index.ts b/packages/automation/index.ts index 93fcea167..9ec6766c2 100644 --- a/packages/automation/index.ts +++ b/packages/automation/index.ts @@ -10,9 +10,9 @@ export * from './domain/value-objects/ScreenRegion'; export * from './domain/value-objects/SessionLifetime'; export * from './domain/value-objects/SessionState'; -export * from './domain/entities/HostedSessionConfig'; -export * from './domain/entities/StepExecution'; export * from './domain/entities/AutomationSession'; +export * from './domain/types/HostedSessionConfig'; +export * from './domain/entities/StepExecution'; export * from './domain/services/PageStateValidator'; export * from './domain/services/StepTransitionValidator'; \ No newline at end of file diff --git a/packages/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts b/packages/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts index 6eb866bb3..e451bdfbe 100644 --- a/packages/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts +++ b/packages/automation/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts @@ -1,5 +1,5 @@ import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort'; -import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig'; import { StepId } from '../../../../domain/value-objects/StepId'; import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort'; import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort'; diff --git a/packages/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts b/packages/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts index 34f1a9a4a..2a24efdc1 100644 --- a/packages/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts +++ b/packages/automation/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts @@ -1,5 +1,5 @@ import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort'; -import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig'; import { StepId } from '../../../../domain/value-objects/StepId'; import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort'; import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort'; diff --git a/packages/identity/domain/entities/Achievement.ts b/packages/identity/domain/entities/Achievement.ts index f12f812a0..fd14c4b4d 100644 --- a/packages/identity/domain/entities/Achievement.ts +++ b/packages/identity/domain/entities/Achievement.ts @@ -1,10 +1,12 @@ /** * Domain Entity: Achievement - * + * * Represents an achievement that can be earned by users. * Achievements are categorized by role (driver, steward, admin) and type. */ +import type { IEntity } from '@gridpilot/shared/domain'; + export type AchievementCategory = 'driver' | 'steward' | 'admin' | 'community'; export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; @@ -30,7 +32,7 @@ export interface AchievementRequirement { operator: '>=' | '>' | '=' | '<' | '<='; } -export class Achievement { +export class Achievement implements IEntity { readonly id: string; readonly name: string; readonly description: string; diff --git a/packages/identity/domain/entities/SponsorAccount.ts b/packages/identity/domain/entities/SponsorAccount.ts index 2f9f9fddc..77f7d07af 100644 --- a/packages/identity/domain/entities/SponsorAccount.ts +++ b/packages/identity/domain/entities/SponsorAccount.ts @@ -6,8 +6,8 @@ */ import { UserId } from '../value-objects/UserId'; -import type { EmailValidationResult } from '../value-objects/EmailAddress'; -import { validateEmail } from '../value-objects/EmailAddress'; +import type { EmailValidationResult } from '../types/EmailAddress'; +import { validateEmail } from '../types/EmailAddress'; export interface SponsorAccountProps { id: UserId; diff --git a/packages/identity/domain/entities/User.ts b/packages/identity/domain/entities/User.ts index 1217a082a..2533ce08a 100644 --- a/packages/identity/domain/entities/User.ts +++ b/packages/identity/domain/entities/User.ts @@ -1,5 +1,5 @@ -import type { EmailValidationResult } from '../value-objects/EmailAddress'; -import { validateEmail } from '../value-objects/EmailAddress'; +import type { EmailValidationResult } from '../types/EmailAddress'; +import { validateEmail } from '../types/EmailAddress'; import { UserId } from '../value-objects/UserId'; export interface UserProps { diff --git a/packages/identity/domain/entities/UserAchievement.ts b/packages/identity/domain/entities/UserAchievement.ts index 7f47729e1..45e16cdcd 100644 --- a/packages/identity/domain/entities/UserAchievement.ts +++ b/packages/identity/domain/entities/UserAchievement.ts @@ -1,9 +1,11 @@ /** * Domain Entity: UserAchievement - * + * * Represents an achievement earned by a specific user. */ +import type { IEntity } from '@gridpilot/shared/domain'; + export interface UserAchievementProps { id: string; userId: string; @@ -13,7 +15,7 @@ export interface UserAchievementProps { progress?: number; // For partial progress tracking (0-100) } -export class UserAchievement { +export class UserAchievement implements IEntity { readonly id: string; readonly userId: string; readonly achievementId: string; diff --git a/packages/identity/domain/types/EmailAddress.ts b/packages/identity/domain/types/EmailAddress.ts new file mode 100644 index 000000000..2e83f1c6e --- /dev/null +++ b/packages/identity/domain/types/EmailAddress.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +/** + * Core email validation schema and helper types. + * Kept in domain/types so domain/value-objects can host the EmailAddress VO class. + */ +export const emailSchema = z + .string() + .trim() + .toLowerCase() + .min(6, 'Email too short') + .max(254, 'Email too long') + .email('Invalid email format'); + +export type EmailValidationSuccess = { + success: true; + email: string; + error?: undefined; +}; + +export type EmailValidationFailure = { + success: false; + email?: undefined; + error: string; +}; + +export type EmailValidationResult = EmailValidationSuccess | EmailValidationFailure; + +/** + * Validate and normalize an email address. + * Mirrors the previous apps/website/lib/email-validation.ts behavior. + */ +export function validateEmail(email: string): EmailValidationResult { + const result = emailSchema.safeParse(email); + + if (result.success) { + return { + success: true, + email: result.data, + }; + } + + return { + success: false, + error: result.error.errors[0]?.message || 'Invalid email', + }; +} + +/** + * Basic disposable email detection. + * This list matches the previous website-local implementation and + * can be extended in the future without changing the public API. + */ +export const DISPOSABLE_DOMAINS = new Set([ + 'tempmail.com', + 'throwaway.email', + 'guerrillamail.com', + 'mailinator.com', + '10minutemail.com', +]); + +export function isDisposableEmail(email: string): boolean { + const domain = email.split('@')[1]?.toLowerCase(); + return domain ? DISPOSABLE_DOMAINS.has(domain) : false; +} \ No newline at end of file diff --git a/packages/identity/domain/value-objects/EmailAddress.ts b/packages/identity/domain/value-objects/EmailAddress.ts index 750290641..ce4db4d0f 100644 --- a/packages/identity/domain/value-objects/EmailAddress.ts +++ b/packages/identity/domain/value-objects/EmailAddress.ts @@ -1,64 +1,48 @@ -import { z } from 'zod'; +import type { IValueObject } from '@gridpilot/shared/domain'; +import type { EmailValidationResult } from '../types/EmailAddress'; +import { validateEmail, isDisposableEmail } from '../types/EmailAddress'; -/** - * Core email validation schema - */ -export const emailSchema = z - .string() - .trim() - .toLowerCase() - .min(6, 'Email too short') - .max(254, 'Email too long') - .email('Invalid email format'); - -export type EmailValidationSuccess = { - success: true; - email: string; - error?: undefined; -}; - -export type EmailValidationFailure = { - success: false; - email?: undefined; - error: string; -}; - -export type EmailValidationResult = EmailValidationSuccess | EmailValidationFailure; - -/** - * Validate and normalize an email address. - * Mirrors the previous apps/website/lib/email-validation.ts behavior. - */ -export function validateEmail(email: string): EmailValidationResult { - const result = emailSchema.safeParse(email); - - if (result.success) { - return { - success: true, - email: result.data, - }; - } - - return { - success: false, - error: result.error.errors[0]?.message || 'Invalid email', - }; +export interface EmailAddressProps { + value: string; } /** - * Basic disposable email detection. - * This list matches the previous website-local implementation and - * can be extended in the future without changing the public API. + * Value Object: EmailAddress + * + * Wraps a validated, normalized email string and provides equality semantics. + * Validation and helper utilities live in domain/types/EmailAddress. */ -export const DISPOSABLE_DOMAINS = new Set([ - 'tempmail.com', - 'throwaway.email', - 'guerrillamail.com', - 'mailinator.com', - '10minutemail.com', -]); +export class EmailAddress implements IValueObject { + public readonly props: EmailAddressProps; -export function isDisposableEmail(email: string): boolean { - const domain = email.split('@')[1]?.toLowerCase(); - return domain ? DISPOSABLE_DOMAINS.has(domain) : false; -} \ No newline at end of file + private constructor(value: string) { + this.props = { value }; + } + + static create(raw: string): EmailAddress { + const result: EmailValidationResult = validateEmail(raw); + if (!result.success) { + throw new Error(result.error); + } + return new EmailAddress(result.email); + } + + static fromValidated(value: string): EmailAddress { + return new EmailAddress(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } + + isDisposable(): boolean { + return isDisposableEmail(this.props.value); + } +} + +export type { EmailValidationResult } from '../types/EmailAddress'; +export { validateEmail, isDisposableEmail } from '../types/EmailAddress'; \ No newline at end of file diff --git a/packages/identity/domain/value-objects/UserId.ts b/packages/identity/domain/value-objects/UserId.ts index 0fb2bcadd..1ebe9c022 100644 --- a/packages/identity/domain/value-objects/UserId.ts +++ b/packages/identity/domain/value-objects/UserId.ts @@ -1,22 +1,32 @@ -export class UserId { - private readonly value: string; +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface UserIdProps { + value: string; +} + +export class UserId implements IValueObject { + public readonly props: UserIdProps; private constructor(value: string) { if (!value || !value.trim()) { throw new Error('UserId cannot be empty'); } - this.value = value; + this.props = { value }; } public static fromString(value: string): UserId { return new UserId(value); } - public toString(): string { - return this.value; + get value(): string { + return this.props.value; } - public equals(other: UserId): boolean { - return this.value === other.value; + public toString(): string { + return this.props.value; + } + + public equals(other: IValueObject): boolean { + return this.props.value === other.props.value; } } \ No newline at end of file diff --git a/packages/identity/domain/value-objects/UserRating.ts b/packages/identity/domain/value-objects/UserRating.ts index e5cac3c3e..b486ce7f9 100644 --- a/packages/identity/domain/value-objects/UserRating.ts +++ b/packages/identity/domain/value-objects/UserRating.ts @@ -1,6 +1,8 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + /** * Value Object: UserRating - * + * * Multi-dimensional rating system for users covering: * - Driver skill: racing ability, lap times, consistency * - Admin competence: league management, event organization @@ -37,27 +39,47 @@ const DEFAULT_DIMENSION: RatingDimension = { lastUpdated: new Date(), }; -export class UserRating { - readonly userId: string; - readonly driver: RatingDimension; - readonly admin: RatingDimension; - readonly steward: RatingDimension; - readonly trust: RatingDimension; - readonly fairness: RatingDimension; - readonly overallReputation: number; - readonly createdAt: Date; - readonly updatedAt: Date; +export class UserRating implements IValueObject { + readonly props: UserRatingProps; private constructor(props: UserRatingProps) { - this.userId = props.userId; - this.driver = props.driver; - this.admin = props.admin; - this.steward = props.steward; - this.trust = props.trust; - this.fairness = props.fairness; - this.overallReputation = props.overallReputation; - this.createdAt = props.createdAt; - this.updatedAt = props.updatedAt; + this.props = props; + } + + get userId(): string { + return this.props.userId; + } + + get driver(): RatingDimension { + return this.props.driver; + } + + get admin(): RatingDimension { + return this.props.admin; + } + + get steward(): RatingDimension { + return this.props.steward; + } + + get trust(): RatingDimension { + return this.props.trust; + } + + get fairness(): RatingDimension { + return this.props.fairness; + } + + get overallReputation(): number { + return this.props.overallReputation; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; } static create(userId: string): UserRating { @@ -83,6 +105,10 @@ export class UserRating { return new UserRating(props); } + equals(other: IValueObject): boolean { + return this.props.userId === other.props.userId; + } + /** * Update driver rating based on race performance */ @@ -241,14 +267,14 @@ export class UserRating { private withUpdates(updates: Partial): UserRating { const newRating = new UserRating({ - ...this, + ...this.props, ...updates, updatedAt: new Date(), }); - + // Recalculate overall reputation return new UserRating({ - ...newRating, + ...newRating.props, overallReputation: newRating.calculateOverallReputation(), }); } diff --git a/packages/media/application/use-cases/RequestAvatarGenerationUseCase.ts b/packages/media/application/use-cases/RequestAvatarGenerationUseCase.ts index a428b9313..caf23e646 100644 --- a/packages/media/application/use-cases/RequestAvatarGenerationUseCase.ts +++ b/packages/media/application/use-cases/RequestAvatarGenerationUseCase.ts @@ -5,10 +5,12 @@ * and creating a generation request. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; import type { FaceValidationPort } from '../ports/FaceValidationPort'; import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; -import { AvatarGenerationRequest, type RacingSuitColor, type AvatarStyle } from '../../domain/entities/AvatarGenerationRequest'; +import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; +import type { RacingSuitColor, AvatarStyle } from '../../domain/types/AvatarGenerationRequest'; export interface RequestAvatarGenerationCommand { userId: string; @@ -24,7 +26,8 @@ export interface RequestAvatarGenerationResult { errorMessage?: string; } -export class RequestAvatarGenerationUseCase { +export class RequestAvatarGenerationUseCase + implements AsyncUseCase { constructor( private readonly avatarRepository: IAvatarGenerationRepository, private readonly faceValidation: FaceValidationPort, @@ -85,7 +88,7 @@ export class RequestAvatarGenerationUseCase { // Generate avatars const generationResult = await this.avatarGeneration.generateAvatars({ - facePhotoUrl: request.facePhotoUrl, + facePhotoUrl: request.facePhotoUrl.value, prompt: request.buildPrompt(), suitColor: request.suitColor, style: request.style, diff --git a/packages/media/application/use-cases/SelectAvatarUseCase.ts b/packages/media/application/use-cases/SelectAvatarUseCase.ts index d28d0ac12..aa5ad209f 100644 --- a/packages/media/application/use-cases/SelectAvatarUseCase.ts +++ b/packages/media/application/use-cases/SelectAvatarUseCase.ts @@ -4,6 +4,7 @@ * Allows a user to select one of the generated avatars as their profile avatar. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; export interface SelectAvatarCommand { @@ -18,7 +19,8 @@ export interface SelectAvatarResult { errorMessage?: string; } -export class SelectAvatarUseCase { +export class SelectAvatarUseCase + implements AsyncUseCase { constructor( private readonly avatarRepository: IAvatarGenerationRepository, ) {} diff --git a/packages/media/domain/entities/AvatarGenerationRequest.ts b/packages/media/domain/entities/AvatarGenerationRequest.ts index f9e364afd..2390bd4e5 100644 --- a/packages/media/domain/entities/AvatarGenerationRequest.ts +++ b/packages/media/domain/entities/AvatarGenerationRequest.ts @@ -1,55 +1,26 @@ /** * Domain Entity: AvatarGenerationRequest - * + * * Represents a request to generate a racing avatar from a face photo. */ - -export type RacingSuitColor = - | 'red' - | 'blue' - | 'green' - | 'yellow' - | 'orange' - | 'purple' - | 'black' - | 'white' - | 'pink' - | 'cyan'; - -export type AvatarStyle = - | 'realistic' - | 'cartoon' - | 'pixel-art'; - -export type AvatarGenerationStatus = - | 'pending' - | 'validating' - | 'generating' - | 'completed' - | 'failed'; - -export interface AvatarGenerationRequestProps { - id: string; - userId: string; - facePhotoUrl: string; - suitColor: RacingSuitColor; - style: AvatarStyle; - status: AvatarGenerationStatus; - generatedAvatarUrls: string[]; - selectedAvatarIndex?: number; - errorMessage?: string; - createdAt: Date; - updatedAt: Date; -} - -export class AvatarGenerationRequest { + +import type { IEntity } from '@gridpilot/shared/domain'; +import type { + AvatarGenerationRequestProps, + AvatarGenerationStatus, + AvatarStyle, + RacingSuitColor, +} from '../types/AvatarGenerationRequest'; +import { MediaUrl } from '../value-objects/MediaUrl'; + +export class AvatarGenerationRequest implements IEntity { readonly id: string; readonly userId: string; - readonly facePhotoUrl: string; + readonly facePhotoUrl: MediaUrl; readonly suitColor: RacingSuitColor; readonly style: AvatarStyle; private _status: AvatarGenerationStatus; - private _generatedAvatarUrls: string[]; + private _generatedAvatarUrls: MediaUrl[]; private _selectedAvatarIndex?: number; private _errorMessage?: string; readonly createdAt: Date; @@ -58,11 +29,11 @@ export class AvatarGenerationRequest { private constructor(props: AvatarGenerationRequestProps) { this.id = props.id; this.userId = props.userId; - this.facePhotoUrl = props.facePhotoUrl; + this.facePhotoUrl = MediaUrl.create(props.facePhotoUrl); this.suitColor = props.suitColor; this.style = props.style; this._status = props.status; - this._generatedAvatarUrls = [...props.generatedAvatarUrls]; + this._generatedAvatarUrls = props.generatedAvatarUrls.map(url => MediaUrl.create(url)); this._selectedAvatarIndex = props.selectedAvatarIndex; this._errorMessage = props.errorMessage; this.createdAt = props.createdAt; @@ -106,7 +77,7 @@ export class AvatarGenerationRequest { } get generatedAvatarUrls(): string[] { - return [...this._generatedAvatarUrls]; + return this._generatedAvatarUrls.map(url => url.value); } get selectedAvatarIndex(): number | undefined { @@ -115,7 +86,7 @@ export class AvatarGenerationRequest { get selectedAvatarUrl(): string | undefined { if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) { - return this._generatedAvatarUrls[this._selectedAvatarIndex]; + return this._generatedAvatarUrls[this._selectedAvatarIndex].value; } return undefined; } @@ -149,7 +120,7 @@ export class AvatarGenerationRequest { throw new Error('At least one avatar URL is required'); } this._status = 'completed'; - this._generatedAvatarUrls = [...avatarUrls]; + this._generatedAvatarUrls = avatarUrls.map(url => MediaUrl.create(url)); this._updatedAt = new Date(); } @@ -204,11 +175,11 @@ export class AvatarGenerationRequest { return { id: this.id, userId: this.userId, - facePhotoUrl: this.facePhotoUrl, + facePhotoUrl: this.facePhotoUrl.value, suitColor: this.suitColor, style: this.style, status: this._status, - generatedAvatarUrls: [...this._generatedAvatarUrls], + generatedAvatarUrls: this._generatedAvatarUrls.map(url => url.value), selectedAvatarIndex: this._selectedAvatarIndex, errorMessage: this._errorMessage, createdAt: this.createdAt, diff --git a/packages/media/domain/types/AvatarGenerationRequest.ts b/packages/media/domain/types/AvatarGenerationRequest.ts new file mode 100644 index 000000000..e7e3f6521 --- /dev/null +++ b/packages/media/domain/types/AvatarGenerationRequest.ts @@ -0,0 +1,44 @@ +/** + * Domain Types: AvatarGenerationRequest + * + * Pure type/config definitions used by the AvatarGenerationRequest entity. + * Kept in domain/types so domain/entities contains only entity classes. + */ + +export type RacingSuitColor = + | 'red' + | 'blue' + | 'green' + | 'yellow' + | 'orange' + | 'purple' + | 'black' + | 'white' + | 'pink' + | 'cyan'; + +export type AvatarStyle = + | 'realistic' + | 'cartoon' + | 'pixel-art'; + +export type AvatarGenerationStatus = + | 'pending' + | 'validating' + | 'generating' + | 'completed' + | 'failed'; + +export interface AvatarGenerationRequestProps { + id: string; + userId: string; + facePhotoUrl: string; + suitColor: RacingSuitColor; + style: AvatarStyle; + status: AvatarGenerationStatus; + generatedAvatarUrls: string[]; + selectedAvatarIndex?: number; + errorMessage?: string; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/packages/media/domain/value-objects/MediaUrl.test.ts b/packages/media/domain/value-objects/MediaUrl.test.ts new file mode 100644 index 000000000..d78d8d04c --- /dev/null +++ b/packages/media/domain/value-objects/MediaUrl.test.ts @@ -0,0 +1,36 @@ +import { MediaUrl } from './MediaUrl'; + +describe('MediaUrl', () => { + it('creates from valid http/https URLs', () => { + expect(MediaUrl.create('http://example.com').value).toBe('http://example.com'); + expect(MediaUrl.create('https://example.com/path').value).toBe('https://example.com/path'); + }); + + it('creates from data URIs', () => { + const url = 'data:image/jpeg;base64,AAA'; + expect(MediaUrl.create(url).value).toBe(url); + }); + + it('creates from root-relative paths', () => { + expect(MediaUrl.create('/images/avatar.png').value).toBe('/images/avatar.png'); + }); + + it('rejects empty or whitespace URLs', () => { + expect(() => MediaUrl.create('')).toThrow(); + expect(() => MediaUrl.create(' ')).toThrow(); + }); + + it('rejects unsupported schemes', () => { + expect(() => MediaUrl.create('ftp://example.com/file')).toThrow(); + expect(() => MediaUrl.create('mailto:user@example.com')).toThrow(); + }); + + it('implements value-based equality', () => { + const a = MediaUrl.create('https://example.com/a.png'); + const b = MediaUrl.create('https://example.com/a.png'); + const c = MediaUrl.create('https://example.com/b.png'); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/media/domain/value-objects/MediaUrl.ts b/packages/media/domain/value-objects/MediaUrl.ts new file mode 100644 index 000000000..f3631aae5 --- /dev/null +++ b/packages/media/domain/value-objects/MediaUrl.ts @@ -0,0 +1,46 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; + +/** + * Value Object: MediaUrl + * + * Represents a validated media URL used for user-uploaded or generated assets. + * For now this is a conservative wrapper around strings with basic invariants: + * - non-empty + * - must start with "http", "https", "data:", or "/" + */ +export interface MediaUrlProps { + value: string; +} + +export class MediaUrl implements IValueObject { + public readonly props: MediaUrlProps; + + private constructor(value: string) { + this.props = { value }; + } + + static create(raw: string): MediaUrl { + const value = raw?.trim(); + + if (!value) { + throw new Error('Media URL cannot be empty'); + } + + const allowedPrefixes = ['http://', 'https://', 'data:', '/']; + const isAllowed = allowedPrefixes.some((prefix) => value.startsWith(prefix)); + + if (!isAllowed) { + throw new Error('Media URL must be http(s), data URI, or root-relative path'); + } + + return new MediaUrl(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } +} \ No newline at end of file diff --git a/packages/notifications/application/index.ts b/packages/notifications/application/index.ts index 967169286..c3078f4e1 100644 --- a/packages/notifications/application/index.ts +++ b/packages/notifications/application/index.ts @@ -23,10 +23,8 @@ export type { NotificationAction, } from '../domain/entities/Notification'; export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference'; -export type { NotificationType } from '../domain/value-objects/NotificationType'; -export type { NotificationChannel } from '../domain/value-objects/NotificationChannel'; -export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType'; -export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel'; +export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes'; +export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes'; // Re-export repository interfaces export type { INotificationRepository } from '../domain/repositories/INotificationRepository'; diff --git a/packages/notifications/application/ports/INotificationGateway.ts b/packages/notifications/application/ports/INotificationGateway.ts index 3de5a26a8..a35d5f895 100644 --- a/packages/notifications/application/ports/INotificationGateway.ts +++ b/packages/notifications/application/ports/INotificationGateway.ts @@ -6,7 +6,7 @@ */ import type { Notification } from '../../domain/entities/Notification'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationChannel } from '../../domain/types/NotificationTypes'; export interface NotificationDeliveryResult { success: boolean; diff --git a/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts b/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts index 38f7b6cc5..6f7360501 100644 --- a/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts +++ b/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts @@ -4,6 +4,7 @@ * Retrieves unread notifications for a recipient. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { Notification } from '../../domain/entities/Notification'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; @@ -12,7 +13,7 @@ export interface UnreadNotificationsResult { totalCount: number; } -export class GetUnreadNotificationsUseCase { +export class GetUnreadNotificationsUseCase implements AsyncUseCase { constructor( private readonly notificationRepository: INotificationRepository, ) {} diff --git a/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts b/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts index 9182b38b6..4f9c52f24 100644 --- a/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts +++ b/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts @@ -4,6 +4,7 @@ * Marks a notification as read. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; @@ -12,7 +13,7 @@ export interface MarkNotificationReadCommand { recipientId: string; // For validation } -export class MarkNotificationReadUseCase { +export class MarkNotificationReadUseCase implements AsyncUseCase { constructor( private readonly notificationRepository: INotificationRepository, ) {} @@ -42,7 +43,7 @@ export class MarkNotificationReadUseCase { * * Marks all notifications as read for a recipient. */ -export class MarkAllNotificationsReadUseCase { +export class MarkAllNotificationsReadUseCase implements AsyncUseCase { constructor( private readonly notificationRepository: INotificationRepository, ) {} @@ -62,7 +63,7 @@ export interface DismissNotificationCommand { recipientId: string; } -export class DismissNotificationUseCase { +export class DismissNotificationUseCase implements AsyncUseCase { constructor( private readonly notificationRepository: INotificationRepository, ) {} diff --git a/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts b/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts index c5b3aaa1c..9169ff514 100644 --- a/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts +++ b/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts @@ -4,16 +4,17 @@ * Manages user notification preferences. */ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import { NotificationPreference } from '../../domain/entities/NotificationPreference'; import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; -import type { NotificationType } from '../../domain/value-objects/NotificationType'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes'; +import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; /** * Query: GetNotificationPreferencesQuery */ -export class GetNotificationPreferencesQuery { +export class GetNotificationPreferencesQuery implements AsyncUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, ) {} @@ -32,7 +33,7 @@ export interface UpdateChannelPreferenceCommand { preference: ChannelPreference; } -export class UpdateChannelPreferenceUseCase { +export class UpdateChannelPreferenceUseCase implements AsyncUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, ) {} @@ -53,7 +54,7 @@ export interface UpdateTypePreferenceCommand { preference: TypePreference; } -export class UpdateTypePreferenceUseCase { +export class UpdateTypePreferenceUseCase implements AsyncUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, ) {} @@ -74,7 +75,7 @@ export interface UpdateQuietHoursCommand { endHour: number | undefined; } -export class UpdateQuietHoursUseCase { +export class UpdateQuietHoursUseCase implements AsyncUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, ) {} @@ -103,7 +104,7 @@ export interface SetDigestModeCommand { frequencyHours?: number; } -export class SetDigestModeUseCase { +export class SetDigestModeUseCase implements AsyncUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, ) {} diff --git a/packages/notifications/application/use-cases/SendNotificationUseCase.ts b/packages/notifications/application/use-cases/SendNotificationUseCase.ts index eb5679858..dd21a647f 100644 --- a/packages/notifications/application/use-cases/SendNotificationUseCase.ts +++ b/packages/notifications/application/use-cases/SendNotificationUseCase.ts @@ -6,13 +6,13 @@ */ import { v4 as uuid } from 'uuid'; +import type { AsyncUseCase } from '@gridpilot/shared/application'; import { Notification } from '../../domain/entities/Notification'; import type { NotificationData } from '../../domain/entities/Notification'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway'; -import type { NotificationType } from '../../domain/value-objects/NotificationType'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes'; export interface SendNotificationCommand { recipientId: string; @@ -43,7 +43,7 @@ export interface SendNotificationResult { deliveryResults: NotificationDeliveryResult[]; } -export class SendNotificationUseCase { +export class SendNotificationUseCase implements AsyncUseCase { constructor( private readonly notificationRepository: INotificationRepository, private readonly preferenceRepository: INotificationPreferenceRepository, diff --git a/packages/notifications/domain/entities/Notification.ts b/packages/notifications/domain/entities/Notification.ts index e4b5e9c32..94fd8f4ed 100644 --- a/packages/notifications/domain/entities/Notification.ts +++ b/packages/notifications/domain/entities/Notification.ts @@ -5,10 +5,11 @@ * Immutable entity with factory methods and domain validation. */ +import type { IEntity } from '@gridpilot/shared/domain'; import { NotificationDomainError } from '../errors/NotificationDomainError'; +import { NotificationId } from '../value-objects/NotificationId'; -import type { NotificationType } from '../value-objects/NotificationType'; -import type { NotificationChannel } from '../value-objects/NotificationChannel'; +import type { NotificationType, NotificationChannel } from '../types/NotificationTypes'; export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required'; @@ -54,7 +55,7 @@ export interface NotificationAction { } export interface NotificationProps { - id: string; + id: NotificationId; /** Driver who receives this notification */ recipientId: string; /** Type of notification */ @@ -85,15 +86,17 @@ export interface NotificationProps { respondedAt?: Date; } -export class Notification { +export class Notification implements IEntity { private constructor(private readonly props: NotificationProps) {} - static create(props: Omit & { + static create(props: Omit & { + id: string; status?: NotificationStatus; createdAt?: Date; urgency?: NotificationUrgency; }): Notification { - if (!props.id) throw new NotificationDomainError('Notification ID is required'); + const id = NotificationId.create(props.id); + if (!props.recipientId) throw new NotificationDomainError('Recipient ID is required'); if (!props.type) throw new NotificationDomainError('Notification type is required'); if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required'); @@ -105,13 +108,14 @@ export class Notification { return new Notification({ ...props, + id, status: props.status ?? defaultStatus, urgency: props.urgency ?? 'silent', createdAt: props.createdAt ?? new Date(), }); } - get id(): string { return this.props.id; } + get id(): string { return this.props.id.value; } get recipientId(): string { return this.props.recipientId; } get type(): NotificationType { return this.props.type; } get title(): string { return this.props.title; } @@ -210,7 +214,10 @@ export class Notification { /** * Convert to plain object for serialization */ - toJSON(): NotificationProps { - return { ...this.props }; + toJSON(): Omit & { id: string } { + return { + ...this.props, + id: this.props.id.value, + }; } } \ No newline at end of file diff --git a/packages/notifications/domain/entities/NotificationPreference.ts b/packages/notifications/domain/entities/NotificationPreference.ts index f544d9518..34f0470b9 100644 --- a/packages/notifications/domain/entities/NotificationPreference.ts +++ b/packages/notifications/domain/entities/NotificationPreference.ts @@ -4,10 +4,10 @@ * Represents a user's notification preferences for different channels and types. */ -import type { NotificationType } from '../value-objects/NotificationType'; -import type { NotificationChannel } from '../value-objects/NotificationChannel'; +import type { IEntity } from '@gridpilot/shared/domain'; +import type { NotificationType, NotificationChannel } from '../types/NotificationTypes'; import { NotificationDomainError } from '../errors/NotificationDomainError'; -import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel'; +import { QuietHours } from '../value-objects/QuietHours'; export interface ChannelPreference { /** Whether this channel is enabled */ @@ -24,6 +24,8 @@ export interface TypePreference { } export interface NotificationPreferenceProps { + /** Aggregate ID for this preference (usually same as driverId) */ + id: string; /** Driver ID this preference belongs to */ driverId: string; /** Global channel preferences */ @@ -42,10 +44,13 @@ export interface NotificationPreferenceProps { updatedAt: Date; } -export class NotificationPreference { +export class NotificationPreference implements IEntity { private constructor(private readonly props: NotificationPreferenceProps) {} - static create(props: Omit & { updatedAt?: Date }): NotificationPreference { + static create( + props: Omit & { updatedAt?: Date }, + ): NotificationPreference { + if (!props.id) throw new NotificationDomainError('Preference ID is required'); if (!props.driverId) throw new NotificationDomainError('Driver ID is required'); if (!props.channels) throw new NotificationDomainError('Channel preferences are required'); @@ -60,6 +65,7 @@ export class NotificationPreference { */ static createDefault(driverId: string): NotificationPreference { return new NotificationPreference({ + id: driverId, driverId, channels: { in_app: { enabled: true }, @@ -72,6 +78,7 @@ export class NotificationPreference { }); } + get id(): string { return this.props.id; } get driverId(): string { return this.props.driverId; } get channels(): Record { return { ...this.props.channels }; } get typePreferences(): Partial> | undefined { @@ -83,6 +90,13 @@ export class NotificationPreference { get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; } get updatedAt(): Date { return this.props.updatedAt; } + get quietHours(): QuietHours | undefined { + if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) { + return undefined; + } + return QuietHours.create(this.props.quietHoursStart, this.props.quietHoursEnd); + } + /** * Check if a specific channel is enabled */ @@ -117,20 +131,13 @@ export class NotificationPreference { * Check if current time is in quiet hours */ isInQuietHours(): boolean { - if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) { + const quietHours = this.quietHours; + if (!quietHours) { return false; } const now = new Date(); - const currentHour = now.getHours(); - - if (this.props.quietHoursStart < this.props.quietHoursEnd) { - // Normal range (e.g., 22:00 to 07:00 next day is NOT this case) - return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd; - } else { - // Overnight range (e.g., 22:00 to 07:00) - return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd; - } + return quietHours.containsHour(now.getHours()); } /** @@ -165,10 +172,12 @@ export class NotificationPreference { * Update quiet hours */ updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference { + const validated = start === undefined || end === undefined ? undefined : QuietHours.create(start, end); + return new NotificationPreference({ ...this.props, - quietHoursStart: start, - quietHoursEnd: end, + quietHoursStart: validated?.props.startHour, + quietHoursEnd: validated?.props.endHour, updatedAt: new Date(), }); } diff --git a/packages/notifications/domain/errors/NotificationDomainError.ts b/packages/notifications/domain/errors/NotificationDomainError.ts index 2c9e91190..dc8f322e1 100644 --- a/packages/notifications/domain/errors/NotificationDomainError.ts +++ b/packages/notifications/domain/errors/NotificationDomainError.ts @@ -1,8 +1,19 @@ -export class NotificationDomainError extends Error { - readonly name: string = 'NotificationDomainError'; +import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors'; - constructor(message: string) { +/** + * Domain Error: NotificationDomainError + * + * Implements the shared IDomainError contract for notification domain failures. + */ +export class NotificationDomainError extends Error implements IDomainError { + readonly name = 'NotificationDomainError'; + readonly type = 'domain' as const; + readonly context = 'notifications'; + readonly kind: CommonDomainErrorKind; + + constructor(message: string, kind: CommonDomainErrorKind = 'validation') { super(message); + this.kind = kind; Object.setPrototypeOf(this, new.target.prototype); } } \ No newline at end of file diff --git a/packages/notifications/domain/repositories/INotificationRepository.ts b/packages/notifications/domain/repositories/INotificationRepository.ts index 22153f90d..759b2eb24 100644 --- a/packages/notifications/domain/repositories/INotificationRepository.ts +++ b/packages/notifications/domain/repositories/INotificationRepository.ts @@ -5,7 +5,7 @@ */ import type { Notification } from '../entities/Notification'; -import type { NotificationType } from '../value-objects/NotificationType'; +import type { NotificationType } from '../types/NotificationTypes'; export interface INotificationRepository { /** diff --git a/packages/notifications/domain/value-objects/NotificationType.ts b/packages/notifications/domain/types/NotificationTypes.ts similarity index 52% rename from packages/notifications/domain/value-objects/NotificationType.ts rename to packages/notifications/domain/types/NotificationTypes.ts index d3f51b0c0..052e601f1 100644 --- a/packages/notifications/domain/value-objects/NotificationType.ts +++ b/packages/notifications/domain/types/NotificationTypes.ts @@ -1,45 +1,88 @@ /** - * Value Object: NotificationType - * + * Domain Types: NotificationChannel, NotificationType and helpers + * + * These are pure type-level/value helpers and intentionally live under domain/types + * rather than domain/value-objects, which is reserved for class-based value objects. + */ + +export type NotificationChannel = + | 'in_app' // In-app notification (stored in database, shown in UI) + | 'email' // Email notification + | 'discord' // Discord webhook notification + | 'push'; // Push notification (future: mobile/browser) + +/** + * Get human-readable name for channel + */ +export function getChannelDisplayName(channel: NotificationChannel): string { + const names: Record = { + in_app: 'In-App', + email: 'Email', + discord: 'Discord', + push: 'Push Notification', + }; + return names[channel]; +} + +/** + * Check if channel requires external integration + */ +export function isExternalChannel(channel: NotificationChannel): boolean { + return channel !== 'in_app'; +} + +/** + * Default channels that are always enabled + */ +export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app']; + +/** + * All available channels + */ +export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push']; + +/** + * Domain Type: NotificationType + * * Defines the types of notifications that can be sent in the system. */ export type NotificationType = // Protest-related - | 'protest_filed' // A protest was filed against you + | 'protest_filed' // A protest was filed against you | 'protest_defense_requested' // Steward requests your defense | 'protest_defense_submitted' // Accused submitted their defense - | 'protest_comment_added' // New comment on a protest you're involved in - | 'protest_vote_required' // You need to vote on a protest - | 'protest_vote_cast' // Someone voted on a protest - | 'protest_resolved' // Protest has been resolved + | 'protest_comment_added' // New comment on a protest you're involved in + | 'protest_vote_required' // You need to vote on a protest + | 'protest_vote_cast' // Someone voted on a protest + | 'protest_resolved' // Protest has been resolved // Penalty-related - | 'penalty_issued' // A penalty was issued to you - | 'penalty_appealed' // Penalty appeal submitted + | 'penalty_issued' // A penalty was issued to you + | 'penalty_appealed' // Penalty appeal submitted | 'penalty_appeal_resolved' // Appeal was resolved // Race-related - | 'race_registration_open' // Race registration is now open - | 'race_reminder' // Race starting soon reminder - | 'race_results_posted' // Race results are available + | 'race_registration_open' // Race registration is now open + | 'race_reminder' // Race starting soon reminder + | 'race_results_posted' // Race results are available // League-related - | 'league_invite' // You were invited to a league - | 'league_join_request' // Someone requested to join your league - | 'league_join_approved' // Your join request was approved - | 'league_join_rejected' // Your join request was rejected - | 'league_role_changed' // Your role in a league changed + | 'league_invite' // You were invited to a league + | 'league_join_request' // Someone requested to join your league + | 'league_join_approved' // Your join request was approved + | 'league_join_rejected' // Your join request was rejected + | 'league_role_changed' // Your role in a league changed // Team-related - | 'team_invite' // You were invited to a team - | 'team_join_request' // Someone requested to join your team - | 'team_join_approved' // Your team join request was approved + | 'team_invite' // You were invited to a team + | 'team_join_request' // Someone requested to join your team + | 'team_join_approved' // Your team join request was approved // Sponsorship-related - | 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity - | 'sponsorship_request_accepted' // Your sponsorship request was accepted - | 'sponsorship_request_rejected' // Your sponsorship request was rejected + | 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity + | 'sponsorship_request_accepted' // Your sponsorship request was accepted + | 'sponsorship_request_rejected' // Your sponsorship request was rejected | 'sponsorship_request_withdrawn' // A sponsor withdrew their request - | 'sponsorship_activated' // Sponsorship is now active - | 'sponsorship_payment_received' // Payment received for sponsorship + | 'sponsorship_activated' // Sponsorship is now active + | 'sponsorship_payment_received' // Payment received for sponsorship // System - | 'system_announcement'; // System-wide announcement + | 'system_announcement'; // System-wide announcement /** * Get human-readable title for notification type diff --git a/packages/notifications/domain/value-objects/NotificationChannel.ts b/packages/notifications/domain/value-objects/NotificationChannel.ts deleted file mode 100644 index fa64e3e71..000000000 --- a/packages/notifications/domain/value-objects/NotificationChannel.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Value Object: NotificationChannel - * - * Defines the delivery channels for notifications. - */ - -export type NotificationChannel = - | 'in_app' // In-app notification (stored in database, shown in UI) - | 'email' // Email notification - | 'discord' // Discord webhook notification - | 'push'; // Push notification (future: mobile/browser) - -/** - * Get human-readable name for channel - */ -export function getChannelDisplayName(channel: NotificationChannel): string { - const names: Record = { - in_app: 'In-App', - email: 'Email', - discord: 'Discord', - push: 'Push Notification', - }; - return names[channel]; -} - -/** - * Check if channel requires external integration - */ -export function isExternalChannel(channel: NotificationChannel): boolean { - return channel !== 'in_app'; -} - -/** - * Default channels that are always enabled - */ -export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app']; - -/** - * All available channels - */ -export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push']; \ No newline at end of file diff --git a/packages/notifications/domain/value-objects/NotificationId.test.ts b/packages/notifications/domain/value-objects/NotificationId.test.ts new file mode 100644 index 000000000..073cc26af --- /dev/null +++ b/packages/notifications/domain/value-objects/NotificationId.test.ts @@ -0,0 +1,38 @@ +import { NotificationId } from './NotificationId'; +import { NotificationDomainError } from '../errors/NotificationDomainError'; + +describe('NotificationId', () => { + it('creates a valid NotificationId from a non-empty string', () => { + const id = NotificationId.create('noti_123'); + + expect(id.value).toBe('noti_123'); + }); + + it('trims whitespace from the raw value', () => { + const id = NotificationId.create(' noti_456 '); + + expect(id.value).toBe('noti_456'); + }); + + it('throws NotificationDomainError for empty string', () => { + expect(() => NotificationId.create('')).toThrow(NotificationDomainError); + expect(() => NotificationId.create(' ')).toThrow(NotificationDomainError); + + try { + NotificationId.create(' '); + } catch (error) { + if (error instanceof NotificationDomainError) { + expect(error.kind).toBe('validation'); + } + } + }); + + it('compares equality based on underlying value', () => { + const a = NotificationId.create('noti_1'); + const b = NotificationId.create('noti_1'); + const c = NotificationId.create('noti_2'); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/notifications/domain/value-objects/NotificationId.ts b/packages/notifications/domain/value-objects/NotificationId.ts new file mode 100644 index 000000000..1ad24e87b --- /dev/null +++ b/packages/notifications/domain/value-objects/NotificationId.ts @@ -0,0 +1,43 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; +import { NotificationDomainError } from '../errors/NotificationDomainError'; + +export interface NotificationIdProps { + value: string; +} + +/** + * Value Object: NotificationId + * + * Encapsulates the unique identifier for a notification and + * enforces basic invariants (non-empty trimmed string). + */ +export class NotificationId implements IValueObject { + public readonly props: NotificationIdProps; + + private constructor(value: string) { + this.props = { value }; + } + + /** + * Factory with validation. + * - Trims input. + * - Requires a non-empty value. + */ + static create(raw: string): NotificationId { + const value = raw.trim(); + + if (!value) { + throw new NotificationDomainError('Notification ID must be a non-empty string', 'validation'); + } + + return new NotificationId(value); + } + + get value(): string { + return this.props.value; + } + + equals(other: IValueObject): boolean { + return this.props.value === other.props.value; + } +} \ No newline at end of file diff --git a/packages/notifications/domain/value-objects/QuietHours.test.ts b/packages/notifications/domain/value-objects/QuietHours.test.ts new file mode 100644 index 000000000..73cd6d03c --- /dev/null +++ b/packages/notifications/domain/value-objects/QuietHours.test.ts @@ -0,0 +1,51 @@ +import { QuietHours } from './QuietHours'; + +describe('QuietHours', () => { + it('creates a valid normal-range window', () => { + const qh = QuietHours.create(9, 17); + expect(qh.props.startHour).toBe(9); + expect(qh.props.endHour).toBe(17); + }); + + it('creates a valid overnight window', () => { + const qh = QuietHours.create(22, 7); + expect(qh.props.startHour).toBe(22); + expect(qh.props.endHour).toBe(7); + }); + + it('throws when hours are out of range', () => { + expect(() => QuietHours.create(-1, 10)).toThrow(); + expect(() => QuietHours.create(0, 24)).toThrow(); + }); + + it('throws when start and end are equal', () => { + expect(() => QuietHours.create(10, 10)).toThrow(); + }); + + it('detects containment for normal range', () => { + const qh = QuietHours.create(9, 17); + expect(qh.containsHour(8)).toBe(false); + expect(qh.containsHour(9)).toBe(true); + expect(qh.containsHour(12)).toBe(true); + expect(qh.containsHour(17)).toBe(false); + }); + + it('detects containment for overnight range', () => { + const qh = QuietHours.create(22, 7); + expect(qh.containsHour(21)).toBe(false); + expect(qh.containsHour(22)).toBe(true); + expect(qh.containsHour(23)).toBe(true); + expect(qh.containsHour(0)).toBe(true); + expect(qh.containsHour(6)).toBe(true); + expect(qh.containsHour(7)).toBe(false); + }); + + it('implements value-based equality', () => { + const a = QuietHours.create(22, 7); + const b = QuietHours.create(22, 7); + const c = QuietHours.create(9, 17); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/notifications/domain/value-objects/QuietHours.ts b/packages/notifications/domain/value-objects/QuietHours.ts new file mode 100644 index 000000000..a63085e0a --- /dev/null +++ b/packages/notifications/domain/value-objects/QuietHours.ts @@ -0,0 +1,72 @@ +import type { IValueObject } from '@gridpilot/shared/domain'; +import { NotificationDomainError } from '../errors/NotificationDomainError'; + +export interface QuietHoursProps { + startHour: number; + endHour: number; +} + +/** + * Value Object: QuietHours + * + * Encapsulates a daily quiet-hours window using 0-23 hour indices and + * provides logic to determine whether a given hour falls within the window. + * + * Supports both normal ranges (start < end) and overnight ranges (start > end). + */ +export class QuietHours implements IValueObject { + public readonly props: QuietHoursProps; + + private constructor(startHour: number, endHour: number) { + this.props = { startHour, endHour }; + } + + /** + * Factory with validation. + * - Hours must be integers between 0 and 23. + * - Start and end cannot be equal (would mean a 0-length window). + */ + static create(startHour: number, endHour: number): QuietHours { + QuietHours.assertValidHour(startHour, 'Start hour'); + QuietHours.assertValidHour(endHour, 'End hour'); + + if (startHour === endHour) { + throw new NotificationDomainError('Quiet hours start and end cannot be the same', 'validation'); + } + + return new QuietHours(startHour, endHour); + } + + private static assertValidHour(value: number, label: string): void { + if (!Number.isInteger(value)) { + throw new NotificationDomainError(`${label} must be an integer between 0 and 23`, 'validation'); + } + if (value < 0 || value > 23) { + throw new NotificationDomainError(`${label} must be between 0 and 23`, 'validation'); + } + } + + /** + * Returns true if the given hour (0-23) lies within the quiet window. + */ + containsHour(hour: number): boolean { + QuietHours.assertValidHour(hour, 'Hour'); + + const { startHour, endHour } = this.props; + + if (startHour < endHour) { + // Normal range (e.g., 22:00 to 23:59 is NOT this case, but 1:00 to 7:00 is) + return hour >= startHour && hour < endHour; + } + + // Overnight range (e.g., 22:00 to 07:00) + return hour >= startHour || hour < endHour; + } + + equals(other: IValueObject): boolean { + return ( + this.props.startHour === other.props.startHour && + this.props.endHour === other.props.endHour + ); + } +} \ No newline at end of file diff --git a/packages/notifications/infrastructure/adapters/DiscordNotificationAdapter.ts b/packages/notifications/infrastructure/adapters/DiscordNotificationAdapter.ts index da5293d6b..b0dfc97a1 100644 --- a/packages/notifications/infrastructure/adapters/DiscordNotificationAdapter.ts +++ b/packages/notifications/infrastructure/adapters/DiscordNotificationAdapter.ts @@ -10,7 +10,7 @@ import type { INotificationGateway, NotificationDeliveryResult } from '../../application/ports/INotificationGateway'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationChannel } from '../../domain/types/NotificationTypes'; export interface DiscordAdapterConfig { webhookUrl?: string; diff --git a/packages/notifications/infrastructure/adapters/EmailNotificationAdapter.ts b/packages/notifications/infrastructure/adapters/EmailNotificationAdapter.ts index 3c68c149a..9bb01d05c 100644 --- a/packages/notifications/infrastructure/adapters/EmailNotificationAdapter.ts +++ b/packages/notifications/infrastructure/adapters/EmailNotificationAdapter.ts @@ -10,7 +10,7 @@ import type { INotificationGateway, NotificationDeliveryResult } from '../../application/ports/INotificationGateway'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationChannel } from '../../domain/types/NotificationTypes'; export interface EmailAdapterConfig { smtpHost?: string; diff --git a/packages/notifications/infrastructure/adapters/InAppNotificationAdapter.ts b/packages/notifications/infrastructure/adapters/InAppNotificationAdapter.ts index 4f16ac088..2f13e473a 100644 --- a/packages/notifications/infrastructure/adapters/InAppNotificationAdapter.ts +++ b/packages/notifications/infrastructure/adapters/InAppNotificationAdapter.ts @@ -10,7 +10,7 @@ import type { INotificationGateway, NotificationDeliveryResult } from '../../application/ports/INotificationGateway'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationChannel } from '../../domain/types/NotificationTypes'; export class InAppNotificationAdapter implements INotificationGateway { private readonly channel: NotificationChannel = 'in_app'; diff --git a/packages/notifications/infrastructure/adapters/NotificationGatewayRegistry.ts b/packages/notifications/infrastructure/adapters/NotificationGatewayRegistry.ts index 2503548e7..2c1eba180 100644 --- a/packages/notifications/infrastructure/adapters/NotificationGatewayRegistry.ts +++ b/packages/notifications/infrastructure/adapters/NotificationGatewayRegistry.ts @@ -5,7 +5,7 @@ */ import type { Notification } from '../../domain/entities/Notification'; -import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; +import type { NotificationChannel } from '../../domain/types/NotificationTypes'; import type { INotificationGateway, INotificationGatewayRegistry, diff --git a/packages/notifications/infrastructure/repositories/InMemoryNotificationRepository.ts b/packages/notifications/infrastructure/repositories/InMemoryNotificationRepository.ts index eb6b55d8a..23eb3940e 100644 --- a/packages/notifications/infrastructure/repositories/InMemoryNotificationRepository.ts +++ b/packages/notifications/infrastructure/repositories/InMemoryNotificationRepository.ts @@ -6,7 +6,7 @@ import { Notification } from '../../domain/entities/Notification'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; -import type { NotificationType } from '../../domain/value-objects/NotificationType'; +import type { NotificationType } from '../../domain/types/NotificationTypes'; export class InMemoryNotificationRepository implements INotificationRepository { private notifications: Map = new Map(); diff --git a/packages/racing/application/dto/ChampionshipStandingsDTO.ts b/packages/racing/application/dto/ChampionshipStandingsDTO.ts index d8104f179..47668906b 100644 --- a/packages/racing/application/dto/ChampionshipStandingsDTO.ts +++ b/packages/racing/application/dto/ChampionshipStandingsDTO.ts @@ -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; diff --git a/packages/racing/application/dto/LeagueConfigFormDTO.ts b/packages/racing/application/dto/LeagueConfigFormDTO.ts index 8a2bef42b..0b9cc5a81 100644 --- a/packages/racing/application/dto/LeagueConfigFormDTO.ts +++ b/packages/racing/application/dto/LeagueConfigFormDTO.ts @@ -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; } /** diff --git a/packages/racing/application/dto/LeagueScheduleDTO.ts b/packages/racing/application/dto/LeagueScheduleDTO.ts index 9a867e743..da082173d 100644 --- a/packages/racing/application/dto/LeagueScheduleDTO.ts +++ b/packages/racing/application/dto/LeagueScheduleDTO.ts @@ -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'; diff --git a/packages/racing/application/dto/TeamCommandAndQueryDTO.ts b/packages/racing/application/dto/TeamCommandAndQueryDTO.ts index c8604a0e6..863f11717 100644 --- a/packages/racing/application/dto/TeamCommandAndQueryDTO.ts +++ b/packages/racing/application/dto/TeamCommandAndQueryDTO.ts @@ -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; diff --git a/packages/racing/application/errors/RacingApplicationError.ts b/packages/racing/application/errors/RacingApplicationError.ts index 08c02b18e..63dd91ae8 100644 --- a/packages/racing/application/errors/RacingApplicationError.ts +++ b/packages/racing/application/errors/RacingApplicationError.ts @@ -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 +{ + 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) { diff --git a/packages/racing/application/index.ts b/packages/racing/application/index.ts index 343865ec9..2b6dba64a 100644 --- a/packages/racing/application/index.ts +++ b/packages/racing/application/index.ts @@ -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'; diff --git a/packages/racing/application/ports/IImageServicePort.ts b/packages/racing/application/ports/IImageServicePort.ts new file mode 100644 index 000000000..409287137 --- /dev/null +++ b/packages/racing/application/ports/IImageServicePort.ts @@ -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; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IDriverTeamPresenter.ts b/packages/racing/application/presenters/IDriverTeamPresenter.ts index 234178ff7..5ab9ea6a8 100644 --- a/packages/racing/application/presenters/IDriverTeamPresenter.ts +++ b/packages/racing/application/presenters/IDriverTeamPresenter.ts @@ -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: { diff --git a/packages/racing/application/presenters/ITeamDetailsPresenter.ts b/packages/racing/application/presenters/ITeamDetailsPresenter.ts index 9aa5ea9de..6aad771ad 100644 --- a/packages/racing/application/presenters/ITeamDetailsPresenter.ts +++ b/packages/racing/application/presenters/ITeamDetailsPresenter.ts @@ -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: { diff --git a/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts b/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts index aae3c90b4..f0aab02bd 100644 --- a/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts +++ b/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts @@ -1,4 +1,4 @@ -import type { TeamJoinRequest } from '../../domain/entities/Team'; +import type { TeamJoinRequest } from '../../domain/types/TeamMembership'; export interface TeamJoinRequestViewModel { requestId: string; diff --git a/packages/racing/application/presenters/ITeamMembersPresenter.ts b/packages/racing/application/presenters/ITeamMembersPresenter.ts index ec0257531..fc372c002 100644 --- a/packages/racing/application/presenters/ITeamMembersPresenter.ts +++ b/packages/racing/application/presenters/ITeamMembersPresenter.ts @@ -1,4 +1,4 @@ -import type { TeamMembership } from '../../domain/entities/Team'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; export interface TeamMemberViewModel { driverId: string; diff --git a/packages/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts b/packages/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts index b43873284..c08a13e65 100644 --- a/packages/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts +++ b/packages/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts @@ -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 { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, diff --git a/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index bb2277ac6..a5925b90d 100644 --- a/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -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 +{ constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, diff --git a/packages/racing/application/use-cases/ApplyPenaltyUseCase.ts b/packages/racing/application/use-cases/ApplyPenaltyUseCase.ts index 2d2557fac..23a58aaba 100644 --- a/packages/racing/application/use-cases/ApplyPenaltyUseCase.ts +++ b/packages/racing/application/use-cases/ApplyPenaltyUseCase.ts @@ -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 { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly protestRepository: IProtestRepository, diff --git a/packages/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts b/packages/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts index 4535c1bb4..5d90a18ec 100644 --- a/packages/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts +++ b/packages/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts @@ -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 { constructor( private readonly membershipRepository: ITeamMembershipRepository, ) {} diff --git a/packages/racing/application/use-cases/CancelRaceUseCase.ts b/packages/racing/application/use-cases/CancelRaceUseCase.ts index eb24c0c27..84181eb21 100644 --- a/packages/racing/application/use-cases/CancelRaceUseCase.ts +++ b/packages/racing/application/use-cases/CancelRaceUseCase.ts @@ -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 { constructor( private readonly raceRepository: IRaceRepository, ) {} diff --git a/packages/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts b/packages/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts index 8e5e018d3..8765eb46f 100644 --- a/packages/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/packages/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -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 { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, diff --git a/packages/racing/application/use-cases/CreateTeamUseCase.ts b/packages/racing/application/use-cases/CreateTeamUseCase.ts index 121678068..7e7841f96 100644 --- a/packages/racing/application/use-cases/CreateTeamUseCase.ts +++ b/packages/racing/application/use-cases/CreateTeamUseCase.ts @@ -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, diff --git a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts index f4ade0a45..76bbd877d 100644 --- a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts +++ b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts @@ -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 +{ constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, diff --git a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts index 7b7ece98a..63a0f8b5a 100644 --- a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts +++ b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts @@ -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 +{ constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, diff --git a/packages/racing/application/use-cases/GetAllRacesPageDataUseCase.ts b/packages/racing/application/use-cases/GetAllRacesPageDataUseCase.ts index 423d428a2..ff43dcb1a 100644 --- a/packages/racing/application/use-cases/GetAllRacesPageDataUseCase.ts +++ b/packages/racing/application/use-cases/GetAllRacesPageDataUseCase.ts @@ -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 { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, diff --git a/packages/racing/application/use-cases/GetAllTeamsUseCase.ts b/packages/racing/application/use-cases/GetAllTeamsUseCase.ts index 8f423e388..a2064942f 100644 --- a/packages/racing/application/use-cases/GetAllTeamsUseCase.ts +++ b/packages/racing/application/use-cases/GetAllTeamsUseCase.ts @@ -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 { constructor( private readonly teamRepository: ITeamRepository, private readonly teamMembershipRepository: ITeamMembershipRepository, diff --git a/packages/racing/application/use-cases/GetDashboardOverviewUseCase.ts b/packages/racing/application/use-cases/GetDashboardOverviewUseCase.ts index 094eb8821..3d61da1f0 100644 --- a/packages/racing/application/use-cases/GetDashboardOverviewUseCase.ts +++ b/packages/racing/application/use-cases/GetDashboardOverviewUseCase.ts @@ -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 { 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, ) {} diff --git a/packages/racing/application/use-cases/GetDriverTeamUseCase.ts b/packages/racing/application/use-cases/GetDriverTeamUseCase.ts index 9d15d0f0a..53415485e 100644 --- a/packages/racing/application/use-cases/GetDriverTeamUseCase.ts +++ b/packages/racing/application/use-cases/GetDriverTeamUseCase.ts @@ -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 { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, diff --git a/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts index d6601f641..4ce58c039 100644 --- a/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -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 { 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, ) {} diff --git a/packages/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts b/packages/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts index bc59e47dd..d1d7b5ce5 100644 --- a/packages/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts +++ b/packages/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts @@ -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 { constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, diff --git a/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts b/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts index 81c7ea538..179ce600e 100644 --- a/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts @@ -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 { constructor( private readonly standingRepository: IStandingRepository, private readonly resultRepository: IResultRepository, diff --git a/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts b/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts index 71e9b202b..7b3a8a062 100644 --- a/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts @@ -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, diff --git a/packages/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts b/packages/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts index 180a7ade4..10088a6c9 100644 --- a/packages/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts @@ -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, diff --git a/packages/racing/application/use-cases/GetLeagueStandingsUseCase.ts b/packages/racing/application/use-cases/GetLeagueStandingsUseCase.ts index e964f74d8..926a91c5b 100644 --- a/packages/racing/application/use-cases/GetLeagueStandingsUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueStandingsUseCase.ts @@ -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 { constructor( private readonly standingRepository: IStandingRepository, public readonly presenter: ILeagueStandingsPresenter, diff --git a/packages/racing/application/use-cases/GetLeagueStatsUseCase.ts b/packages/racing/application/use-cases/GetLeagueStatsUseCase.ts index 00c69a6f8..1df9ef2de 100644 --- a/packages/racing/application/use-cases/GetLeagueStatsUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueStatsUseCase.ts @@ -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 { private readonly sofCalculator: StrengthOfFieldCalculator; constructor( diff --git a/packages/racing/application/use-cases/GetProfileOverviewUseCase.ts b/packages/racing/application/use-cases/GetProfileOverviewUseCase.ts index a9feaf76b..e02aed12e 100644 --- a/packages/racing/application/use-cases/GetProfileOverviewUseCase.ts +++ b/packages/racing/application/use-cases/GetProfileOverviewUseCase.ts @@ -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, diff --git a/packages/racing/application/use-cases/GetRaceDetailUseCase.ts b/packages/racing/application/use-cases/GetRaceDetailUseCase.ts index d27f622cb..6bf2d989e 100644 --- a/packages/racing/application/use-cases/GetRaceDetailUseCase.ts +++ b/packages/racing/application/use-cases/GetRaceDetailUseCase.ts @@ -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, ) {} diff --git a/packages/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts b/packages/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts index e3abffbba..02a849ce6 100644 --- a/packages/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts +++ b/packages/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts @@ -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, ) {} diff --git a/packages/racing/application/use-cases/GetTeamMembersUseCase.ts b/packages/racing/application/use-cases/GetTeamMembersUseCase.ts index 48b5573eb..f17d52cfd 100644 --- a/packages/racing/application/use-cases/GetTeamMembersUseCase.ts +++ b/packages/racing/application/use-cases/GetTeamMembersUseCase.ts @@ -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, ) {} diff --git a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts index 1b2ff7e19..69b62f6be 100644 --- a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts +++ b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts @@ -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 +{ constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, diff --git a/packages/racing/application/use-cases/JoinLeagueUseCase.ts b/packages/racing/application/use-cases/JoinLeagueUseCase.ts index facdb6c1b..d0cf32ec3 100644 --- a/packages/racing/application/use-cases/JoinLeagueUseCase.ts +++ b/packages/racing/application/use-cases/JoinLeagueUseCase.ts @@ -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 { 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); } diff --git a/packages/racing/application/use-cases/JoinTeamUseCase.ts b/packages/racing/application/use-cases/JoinTeamUseCase.ts index 55d6461e9..b5531310e 100644 --- a/packages/racing/application/use-cases/JoinTeamUseCase.ts +++ b/packages/racing/application/use-cases/JoinTeamUseCase.ts @@ -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 { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, diff --git a/packages/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts b/packages/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts index f359b065d..7a1952668 100644 --- a/packages/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts +++ b/packages/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts @@ -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'; diff --git a/packages/racing/application/use-cases/RegisterForRaceUseCase.ts b/packages/racing/application/use-cases/RegisterForRaceUseCase.ts index e6fb68eaf..c13b88915 100644 --- a/packages/racing/application/use-cases/RegisterForRaceUseCase.ts +++ b/packages/racing/application/use-cases/RegisterForRaceUseCase.ts @@ -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 +{ 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); } diff --git a/packages/racing/application/use-cases/RequestProtestDefenseUseCase.ts b/packages/racing/application/use-cases/RequestProtestDefenseUseCase.ts index de3072ecc..fbb68fd70 100644 --- a/packages/racing/application/use-cases/RequestProtestDefenseUseCase.ts +++ b/packages/racing/application/use-cases/RequestProtestDefenseUseCase.ts @@ -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; diff --git a/packages/racing/application/use-cases/UpdateTeamUseCase.ts b/packages/racing/application/use-cases/UpdateTeamUseCase.ts index 8b382bb65..d4d340465 100644 --- a/packages/racing/application/use-cases/UpdateTeamUseCase.ts +++ b/packages/racing/application/use-cases/UpdateTeamUseCase.ts @@ -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 { diff --git a/packages/racing/domain/entities/Car.ts b/packages/racing/domain/entities/Car.ts index 589c244e3..38b35b81e 100644 --- a/packages/racing/domain/entities/Car.ts +++ b/packages/racing/domain/entities/Car.ts @@ -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 { readonly id: string; readonly name: string; readonly shortName: string; diff --git a/packages/racing/domain/entities/ChampionshipStanding.ts b/packages/racing/domain/entities/ChampionshipStanding.ts index 2a109b808..44dca5a00 100644 --- a/packages/racing/domain/entities/ChampionshipStanding.ts +++ b/packages/racing/domain/entities/ChampionshipStanding.ts @@ -1,4 +1,4 @@ -import type { ParticipantRef } from '../value-objects/ParticipantRef'; +import type { ParticipantRef } from '../types/ParticipantRef'; export class ChampionshipStanding { readonly seasonId: string; diff --git a/packages/racing/domain/entities/Driver.ts b/packages/racing/domain/entities/Driver.ts index 0cedf3014..51d46777b 100644 --- a/packages/racing/domain/entities/Driver.ts +++ b/packages/racing/domain/entities/Driver.ts @@ -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 { readonly id: string; readonly iracingId: string; readonly name: string; diff --git a/packages/racing/domain/entities/DriverLivery.ts b/packages/racing/domain/entities/DriverLivery.ts index 994f7de5c..70e754067 100644 --- a/packages/racing/domain/entities/DriverLivery.ts +++ b/packages/racing/domain/entities/DriverLivery.ts @@ -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 { readonly id: string; readonly driverId: string; readonly gameId: string; diff --git a/packages/racing/domain/entities/Game.ts b/packages/racing/domain/entities/Game.ts index faa92a2c2..0a90981c5 100644 --- a/packages/racing/domain/entities/Game.ts +++ b/packages/racing/domain/entities/Game.ts @@ -1,6 +1,7 @@ import { RacingDomainValidationError } from '../errors/RacingDomainError'; - -export class Game { +import type { IEntity } from '@gridpilot/shared/domain'; + +export class Game implements IEntity { readonly id: string; readonly name: string; diff --git a/packages/racing/domain/entities/League.ts b/packages/racing/domain/entities/League.ts index 0dd6678e6..fea67a70a 100644 --- a/packages/racing/domain/entities/League.ts +++ b/packages/racing/domain/entities/League.ts @@ -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 { readonly id: string; readonly name: string; readonly description: string; diff --git a/packages/racing/domain/entities/LeagueMembership.ts b/packages/racing/domain/entities/LeagueMembership.ts index 97277fe5d..d6928e402 100644 --- a/packages/racing/domain/entities/LeagueMembership.ts +++ b/packages/racing/domain/entities/LeagueMembership.ts @@ -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 { + readonly id: string; + readonly leagueId: string; + readonly driverId: string; + readonly role: MembershipRole; + readonly status: MembershipStatus; + readonly joinedAt: Date; + + private constructor(props: Required) { + 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 { diff --git a/packages/racing/domain/entities/LeagueScoringConfig.ts b/packages/racing/domain/entities/LeagueScoringConfig.ts index 49d145dfe..fa7b1cc97 100644 --- a/packages/racing/domain/entities/LeagueScoringConfig.ts +++ b/packages/racing/domain/entities/LeagueScoringConfig.ts @@ -1,4 +1,4 @@ -import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig'; +import type { ChampionshipConfig } from '../types/ChampionshipConfig'; export interface LeagueScoringConfig { id: string; diff --git a/packages/racing/domain/entities/LeagueWallet.ts b/packages/racing/domain/entities/LeagueWallet.ts index 78860e2a7..901f2db0d 100644 --- a/packages/racing/domain/entities/LeagueWallet.ts +++ b/packages/racing/domain/entities/LeagueWallet.ts @@ -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 { readonly id: string; readonly leagueId: string; readonly balance: Money; diff --git a/packages/racing/domain/entities/LiveryTemplate.ts b/packages/racing/domain/entities/LiveryTemplate.ts index b9be7cb95..1965f3b83 100644 --- a/packages/racing/domain/entities/LiveryTemplate.ts +++ b/packages/racing/domain/entities/LiveryTemplate.ts @@ -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 { readonly id: string; readonly leagueId: string; readonly seasonId: string; diff --git a/packages/racing/domain/entities/Penalty.ts b/packages/racing/domain/entities/Penalty.ts index 4ec68bab7..c96cdf483 100644 --- a/packages/racing/domain/entities/Penalty.ts +++ b/packages/racing/domain/entities/Penalty.ts @@ -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 { private constructor(private readonly props: PenaltyProps) {} static create(props: PenaltyProps): Penalty { diff --git a/packages/racing/domain/entities/Prize.ts b/packages/racing/domain/entities/Prize.ts index 1b7490579..6e8cc53f2 100644 --- a/packages/racing/domain/entities/Prize.ts +++ b/packages/racing/domain/entities/Prize.ts @@ -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 { readonly id: string; readonly seasonId: string; readonly position: number; diff --git a/packages/racing/domain/entities/Protest.ts b/packages/racing/domain/entities/Protest.ts index 5b2c817d0..a73f59861 100644 --- a/packages/racing/domain/entities/Protest.ts +++ b/packages/racing/domain/entities/Protest.ts @@ -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 { private constructor(private readonly props: ProtestProps) {} static create(props: ProtestProps): Protest { diff --git a/packages/racing/domain/entities/Race.ts b/packages/racing/domain/entities/Race.ts index aae0ecd12..eab6c472e 100644 --- a/packages/racing/domain/entities/Race.ts +++ b/packages/racing/domain/entities/Race.ts @@ -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 { readonly id: string; readonly leagueId: string; readonly scheduledAt: Date; diff --git a/packages/racing/domain/entities/RaceRegistration.ts b/packages/racing/domain/entities/RaceRegistration.ts index d1581496e..907b4e08a 100644 --- a/packages/racing/domain/entities/RaceRegistration.ts +++ b/packages/racing/domain/entities/RaceRegistration.ts @@ -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 { + readonly id: string; + readonly raceId: string; + readonly driverId: string; + readonly registeredAt: Date; + + private constructor(props: Required) { + 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'); + } + } } \ No newline at end of file diff --git a/packages/racing/domain/entities/Result.ts b/packages/racing/domain/entities/Result.ts index 5d2813254..e0a61eb99 100644 --- a/packages/racing/domain/entities/Result.ts +++ b/packages/racing/domain/entities/Result.ts @@ -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 { readonly id: string; readonly raceId: string; readonly driverId: string; diff --git a/packages/racing/domain/entities/Season.ts b/packages/racing/domain/entities/Season.ts index 502c90fed..690003e41 100644 --- a/packages/racing/domain/entities/Season.ts +++ b/packages/racing/domain/entities/Season.ts @@ -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 { readonly id: string; readonly leagueId: string; readonly gameId: string; diff --git a/packages/racing/domain/entities/SeasonSponsorship.ts b/packages/racing/domain/entities/SeasonSponsorship.ts index 9e17ace28..e22fc2225 100644 --- a/packages/racing/domain/entities/SeasonSponsorship.ts +++ b/packages/racing/domain/entities/SeasonSponsorship.ts @@ -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 { readonly id: string; readonly seasonId: string; readonly sponsorId: string; diff --git a/packages/racing/domain/entities/Sponsor.ts b/packages/racing/domain/entities/Sponsor.ts index bbfc9887d..44ce069b7 100644 --- a/packages/racing/domain/entities/Sponsor.ts +++ b/packages/racing/domain/entities/Sponsor.ts @@ -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 { readonly id: string; readonly name: string; readonly contactEmail: string; diff --git a/packages/racing/domain/entities/SponsorshipRequest.ts b/packages/racing/domain/entities/SponsorshipRequest.ts index 35eb6c6b1..3f12d7e1c 100644 --- a/packages/racing/domain/entities/SponsorshipRequest.ts +++ b/packages/racing/domain/entities/SponsorshipRequest.ts @@ -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 { readonly id: string; readonly sponsorId: string; readonly entityType: SponsorableEntityType; diff --git a/packages/racing/domain/entities/Standing.ts b/packages/racing/domain/entities/Standing.ts index 0b5afd004..2c814b4c2 100644 --- a/packages/racing/domain/entities/Standing.ts +++ b/packages/racing/domain/entities/Standing.ts @@ -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 { + 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, diff --git a/packages/racing/domain/entities/Team.ts b/packages/racing/domain/entities/Team.ts index cc94084b5..e31fb2503 100644 --- a/packages/racing/domain/entities/Team.ts +++ b/packages/racing/domain/entities/Team.ts @@ -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 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 { + 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'); + } + } } \ No newline at end of file diff --git a/packages/racing/domain/entities/Track.ts b/packages/racing/domain/entities/Track.ts index 6d5ce5903..3fdaaa269 100644 --- a/packages/racing/domain/entities/Track.ts +++ b/packages/racing/domain/entities/Track.ts @@ -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 { readonly id: string; readonly name: string; readonly shortName: string; diff --git a/packages/racing/domain/entities/Transaction.ts b/packages/racing/domain/entities/Transaction.ts index 35ad96737..8bd329d1d 100644 --- a/packages/racing/domain/entities/Transaction.ts +++ b/packages/racing/domain/entities/Transaction.ts @@ -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; } - -export class Transaction { + +export class Transaction implements IEntity { readonly id: string; readonly walletId: string; readonly type: TransactionType; diff --git a/packages/racing/domain/errors/RacingDomainError.ts b/packages/racing/domain/errors/RacingDomainError.ts index f490d127b..fbaa901f9 100644 --- a/packages/racing/domain/errors/RacingDomainError.ts +++ b/packages/racing/domain/errors/RacingDomainError.ts @@ -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 { + 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) { diff --git a/packages/racing/domain/repositories/ITeamMembershipRepository.ts b/packages/racing/domain/repositories/ITeamMembershipRepository.ts index 74fc2a23c..c1b7b44cc 100644 --- a/packages/racing/domain/repositories/ITeamMembershipRepository.ts +++ b/packages/racing/domain/repositories/ITeamMembershipRepository.ts @@ -8,7 +8,7 @@ import type { TeamMembership, TeamJoinRequest, -} from '../entities/Team'; +} from '../types/TeamMembership'; export interface ITeamMembershipRepository { /** diff --git a/packages/racing/domain/services/ChampionshipAggregator.ts b/packages/racing/domain/services/ChampionshipAggregator.ts index 265bf922b..aaf2090c2 100644 --- a/packages/racing/domain/services/ChampionshipAggregator.ts +++ b/packages/racing/domain/services/ChampionshipAggregator.ts @@ -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'; diff --git a/packages/racing/domain/services/DropScoreApplier.ts b/packages/racing/domain/services/DropScoreApplier.ts index e8a23c915..5426189aa 100644 --- a/packages/racing/domain/services/DropScoreApplier.ts +++ b/packages/racing/domain/services/DropScoreApplier.ts @@ -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 { + 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); diff --git a/packages/racing/domain/services/EventScoringService.ts b/packages/racing/domain/services/EventScoringService.ts index dc65aa3f8..39f59de53 100644 --- a/packages/racing/domain/services/EventScoringService.ts +++ b/packages/racing/domain/services/EventScoringService.ts @@ -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 +{ + calculate(input: EventScoringInput): ParticipantEventPoints[] { + return this.scoreSession(input); + } + + scoreSession(params: EventScoringInput): ParticipantEventPoints[] { const { championship, sessionType, results } = params; const pointsTable = this.getPointsTableForSession(championship, sessionType); diff --git a/packages/racing/domain/services/IImageService.ts b/packages/racing/domain/services/IImageService.ts index 73eb3cfdc..9e04bc1e8 100644 --- a/packages/racing/domain/services/IImageService.ts +++ b/packages/racing/domain/services/IImageService.ts @@ -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; -} \ No newline at end of file +export type { IImageServicePort as IImageService } from '../../application/ports/IImageServicePort'; \ No newline at end of file diff --git a/packages/racing/domain/services/ScheduleCalculator.test.ts b/packages/racing/domain/services/ScheduleCalculator.test.ts index 8d861c837..08285744a 100644 --- a/packages/racing/domain/services/ScheduleCalculator.test.ts +++ b/packages/racing/domain/services/ScheduleCalculator.test.ts @@ -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', () => { diff --git a/packages/racing/domain/services/ScheduleCalculator.ts b/packages/racing/domain/services/ScheduleCalculator.ts index caedd2412..8aee14f83 100644 --- a/packages/racing/domain/services/ScheduleCalculator.ts +++ b/packages/racing/domain/services/ScheduleCalculator.ts @@ -1,4 +1,4 @@ -import type { Weekday } from '../value-objects/Weekday'; +import type { Weekday } from '../types/Weekday'; export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday'; diff --git a/packages/racing/domain/services/SeasonScheduleGenerator.ts b/packages/racing/domain/services/SeasonScheduleGenerator.ts index a1e8a9299..e4e5abc7b 100644 --- a/packages/racing/domain/services/SeasonScheduleGenerator.ts +++ b/packages/racing/domain/services/SeasonScheduleGenerator.ts @@ -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 +{ + calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] { + return SeasonScheduleGenerator.generateSlots(schedule); + } } \ No newline at end of file diff --git a/packages/racing/domain/services/SkillLevelService.ts b/packages/racing/domain/services/SkillLevelService.ts index 1231871ca..b894c1001 100644 --- a/packages/racing/domain/services/SkillLevelService.ts +++ b/packages/racing/domain/services/SkillLevelService.ts @@ -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. diff --git a/packages/racing/domain/services/StrengthOfFieldCalculator.ts b/packages/racing/domain/services/StrengthOfFieldCalculator.ts index fc59632bc..01e1a2d9e 100644 --- a/packages/racing/domain/services/StrengthOfFieldCalculator.ts +++ b/packages/racing/domain/services/StrengthOfFieldCalculator.ts @@ -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 +{ calculate(driverRatings: DriverRating[]): number | null { if (driverRatings.length === 0) { return null; diff --git a/packages/racing/domain/value-objects/BonusRule.ts b/packages/racing/domain/types/BonusRule.ts similarity index 100% rename from packages/racing/domain/value-objects/BonusRule.ts rename to packages/racing/domain/types/BonusRule.ts diff --git a/packages/racing/domain/types/ChampionshipConfig.ts b/packages/racing/domain/types/ChampionshipConfig.ts new file mode 100644 index 000000000..4deca001c --- /dev/null +++ b/packages/racing/domain/types/ChampionshipConfig.ts @@ -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; + bonusRulesBySessionType?: Record; + dropScorePolicy: DropScorePolicy; +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/ChampionshipType.ts b/packages/racing/domain/types/ChampionshipType.ts similarity index 100% rename from packages/racing/domain/value-objects/ChampionshipType.ts rename to packages/racing/domain/types/ChampionshipType.ts diff --git a/packages/racing/domain/value-objects/DropScorePolicy.ts b/packages/racing/domain/types/DropScorePolicy.ts similarity index 100% rename from packages/racing/domain/value-objects/DropScorePolicy.ts rename to packages/racing/domain/types/DropScorePolicy.ts diff --git a/packages/racing/domain/value-objects/LeagueRoles.ts b/packages/racing/domain/types/LeagueRoles.ts similarity index 97% rename from packages/racing/domain/value-objects/LeagueRoles.ts rename to packages/racing/domain/types/LeagueRoles.ts index 2b6ea775d..e35877735 100644 --- a/packages/racing/domain/value-objects/LeagueRoles.ts +++ b/packages/racing/domain/types/LeagueRoles.ts @@ -1,6 +1,6 @@ /** - * Domain Value Object: LeagueRoles - * + * Domain Types/Utilities: LeagueRoles + * * Utility functions for working with league membership roles. */ diff --git a/packages/racing/domain/value-objects/ParticipantRef.ts b/packages/racing/domain/types/ParticipantRef.ts similarity index 100% rename from packages/racing/domain/value-objects/ParticipantRef.ts rename to packages/racing/domain/types/ParticipantRef.ts diff --git a/packages/racing/domain/value-objects/SessionType.ts b/packages/racing/domain/types/SessionType.ts similarity index 100% rename from packages/racing/domain/value-objects/SessionType.ts rename to packages/racing/domain/types/SessionType.ts diff --git a/packages/racing/domain/types/TeamMembership.ts b/packages/racing/domain/types/TeamMembership.ts new file mode 100644 index 000000000..3ad44a675 --- /dev/null +++ b/packages/racing/domain/types/TeamMembership.ts @@ -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; +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/Weekday.ts b/packages/racing/domain/types/Weekday.ts similarity index 100% rename from packages/racing/domain/value-objects/Weekday.ts rename to packages/racing/domain/types/Weekday.ts diff --git a/packages/racing/domain/value-objects/ChampionshipConfig.ts b/packages/racing/domain/value-objects/ChampionshipConfig.ts deleted file mode 100644 index 4f8b5af98..000000000 --- a/packages/racing/domain/value-objects/ChampionshipConfig.ts +++ /dev/null @@ -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; - bonusRulesBySessionType?: Record; - dropScorePolicy: DropScorePolicy; -} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/GameConstraints.ts b/packages/racing/domain/value-objects/GameConstraints.ts index 0bb376a12..951f82797 100644 --- a/packages/racing/domain/value-objects/GameConstraints.ts +++ b/packages/racing/domain/value-objects/GameConstraints.ts @@ -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 = { }, }; -export class GameConstraints { +export class GameConstraints implements IValueObject { 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): boolean { + return this.props.gameId === other.props.gameId; + } + /** * Get constraints for a specific game */ diff --git a/packages/racing/domain/value-objects/LeagueDescription.ts b/packages/racing/domain/value-objects/LeagueDescription.ts index 62c1fb67c..02219ebae 100644 --- a/packages/racing/domain/value-objects/LeagueDescription.ts +++ b/packages/racing/domain/value-objects/LeagueDescription.ts @@ -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 { 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): boolean { + return this.props.value === other.props.value; } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/LeagueName.ts b/packages/racing/domain/value-objects/LeagueName.ts index 11cce95eb..e135dc9a4 100644 --- a/packages/racing/domain/value-objects/LeagueName.ts +++ b/packages/racing/domain/value-objects/LeagueName.ts @@ -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 { 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): boolean { + return this.props.value === other.props.value; } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/LeagueTimezone.ts b/packages/racing/domain/value-objects/LeagueTimezone.ts index 6dab9f34d..0c41d85a1 100644 --- a/packages/racing/domain/value-objects/LeagueTimezone.ts +++ b/packages/racing/domain/value-objects/LeagueTimezone.ts @@ -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 { 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): boolean { + return this.props.id === other.props.id; + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/LeagueVisibility.ts b/packages/racing/domain/value-objects/LeagueVisibility.ts index fbcbdb0f0..5828b7276 100644 --- a/packages/racing/domain/value-objects/LeagueVisibility.ts +++ b/packages/racing/domain/value-objects/LeagueVisibility.ts @@ -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 { 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): boolean { + return this.props.type === other.props.type; } } diff --git a/packages/racing/domain/value-objects/LiveryDecal.ts b/packages/racing/domain/value-objects/LiveryDecal.ts index c98f6421a..f97270832 100644 --- a/packages/racing/domain/value-objects/LiveryDecal.ts +++ b/packages/racing/domain/value-objects/LiveryDecal.ts @@ -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 { 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): 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 + ); + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/MembershipFee.ts b/packages/racing/domain/value-objects/MembershipFee.ts index b69507a81..c97538463 100644 --- a/packages/racing/domain/value-objects/MembershipFee.ts +++ b/packages/racing/domain/value-objects/MembershipFee.ts @@ -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 { 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): 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 */ diff --git a/packages/racing/domain/value-objects/Money.ts b/packages/racing/domain/value-objects/Money.ts index f250e4f49..4b10949d6 100644 --- a/packages/racing/domain/value-objects/Money.ts +++ b/packages/racing/domain/value-objects/Money.ts @@ -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 { 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): boolean { + const a = this.props; + const b = other.props; + return a.amount === b.amount && a.currency === b.currency; } /** diff --git a/packages/racing/domain/value-objects/MonthlyRecurrencePattern.ts b/packages/racing/domain/value-objects/MonthlyRecurrencePattern.ts index b4c64fb10..580c84bfa 100644 --- a/packages/racing/domain/value-objects/MonthlyRecurrencePattern.ts +++ b/packages/racing/domain/value-objects/MonthlyRecurrencePattern.ts @@ -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 { 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): boolean { + const a = this.props; + const b = other.props; + return a.ordinal === b.ordinal && a.weekday === b.weekday; + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/PointsTable.ts b/packages/racing/domain/value-objects/PointsTable.ts index a772e48c5..128963928 100644 --- a/packages/racing/domain/value-objects/PointsTable.ts +++ b/packages/racing/domain/value-objects/PointsTable.ts @@ -1,4 +1,10 @@ -export class PointsTable { +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface PointsTableProps { + pointsByPosition: Map; +} + +export class PointsTable implements IValueObject { private readonly pointsByPosition: Map; constructor(pointsByPosition: Record | Map) { @@ -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): 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; + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/RaceTimeOfDay.ts b/packages/racing/domain/value-objects/RaceTimeOfDay.ts index 31576684c..fbebd478a 100644 --- a/packages/racing/domain/value-objects/RaceTimeOfDay.ts +++ b/packages/racing/domain/value-objects/RaceTimeOfDay.ts @@ -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 { 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): boolean { + const a = this.props; + const b = other.props; + return a.hour === b.hour && a.minute === b.minute; + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/RecurrenceStrategy.ts b/packages/racing/domain/value-objects/RecurrenceStrategy.ts deleted file mode 100644 index 49297bcab..000000000 --- a/packages/racing/domain/value-objects/RecurrenceStrategy.ts +++ /dev/null @@ -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, - }; - } -} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/ScheduledRaceSlot.ts b/packages/racing/domain/value-objects/ScheduledRaceSlot.ts index 5279cd573..832400a91 100644 --- a/packages/racing/domain/value-objects/ScheduledRaceSlot.ts +++ b/packages/racing/domain/value-objects/ScheduledRaceSlot.ts @@ -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 { 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): 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) + ); + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/SeasonSchedule.ts b/packages/racing/domain/value-objects/SeasonSchedule.ts index 16e89fc2d..aaf4e6ebf 100644 --- a/packages/racing/domain/value-objects/SeasonSchedule.ts +++ b/packages/racing/domain/value-objects/SeasonSchedule.ts @@ -2,8 +2,17 @@ import { RaceTimeOfDay } from './RaceTimeOfDay'; import { LeagueTimezone } from './LeagueTimezone'; import type { RecurrenceStrategy } from './RecurrenceStrategy'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { IValueObject } from '@gridpilot/shared/domain'; -export class SeasonSchedule { +export interface SeasonScheduleProps { + startDate: Date; + timeOfDay: RaceTimeOfDay; + timezone: LeagueTimezone; + recurrence: RecurrenceStrategy; + plannedRounds: number; +} + +export class SeasonSchedule implements IValueObject { readonly startDate: Date; readonly timeOfDay: RaceTimeOfDay; readonly timezone: LeagueTimezone; @@ -34,4 +43,26 @@ export class SeasonSchedule { this.recurrence = params.recurrence; this.plannedRounds = params.plannedRounds; } + + get props(): SeasonScheduleProps { + return { + startDate: this.startDate, + timeOfDay: this.timeOfDay, + timezone: this.timezone, + recurrence: this.recurrence, + plannedRounds: this.plannedRounds, + }; + } + + equals(other: IValueObject): boolean { + const a = this.props; + const b = other.props; + return ( + a.startDate.getTime() === b.startDate.getTime() && + a.timeOfDay.equals(b.timeOfDay) && + a.timezone.equals(b.timezone) && + a.recurrence.kind === b.recurrence.kind && + a.plannedRounds === b.plannedRounds + ); + } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/SponsorshipPricing.ts b/packages/racing/domain/value-objects/SponsorshipPricing.ts index f7bb0f37b..f1cb78d5a 100644 --- a/packages/racing/domain/value-objects/SponsorshipPricing.ts +++ b/packages/racing/domain/value-objects/SponsorshipPricing.ts @@ -1,11 +1,12 @@ /** * Value Object: SponsorshipPricing - * + * * Represents the sponsorship slot configuration and pricing for any sponsorable entity. * Used by drivers, teams, races, and leagues to define their sponsorship offerings. */ import { Money } from './Money'; +import type { IValueObject } from '@gridpilot/shared/domain'; export interface SponsorshipSlotConfig { tier: 'main' | 'secondary'; @@ -22,7 +23,7 @@ export interface SponsorshipPricingProps { customRequirements?: string; } -export class SponsorshipPricing { +export class SponsorshipPricing implements IValueObject { readonly mainSlot?: SponsorshipSlotConfig; readonly secondarySlots?: SponsorshipSlotConfig; readonly acceptingApplications: boolean; diff --git a/packages/racing/domain/value-objects/WeekdaySet.ts b/packages/racing/domain/value-objects/WeekdaySet.ts index 834156ba2..7ce575284 100644 --- a/packages/racing/domain/value-objects/WeekdaySet.ts +++ b/packages/racing/domain/value-objects/WeekdaySet.ts @@ -1,19 +1,28 @@ import type { Weekday } from './Weekday'; import { weekdayToIndex } from './Weekday'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; - -export class WeekdaySet { +import type { IValueObject } from '@gridpilot/shared/domain'; + +export interface WeekdaySetProps { + days: Weekday[]; +} + +export class WeekdaySet implements IValueObject { private readonly days: Weekday[]; constructor(days: Weekday[]) { if (!Array.isArray(days) || days.length === 0) { throw new RacingDomainValidationError('WeekdaySet requires at least one weekday'); } - + const unique = Array.from(new Set(days)); this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b)); } + get props(): WeekdaySetProps { + return { days: [...this.days] }; + } + getAll(): Weekday[] { return [...this.days]; } @@ -21,4 +30,11 @@ export class WeekdaySet { includes(day: Weekday): boolean { return this.days.includes(day); } + + equals(other: IValueObject): boolean { + const a = this.props.days; + const b = other.props.days; + if (a.length !== b.length) return false; + return a.every((day, index) => day === b[index]); + } } \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemoryScoringRepositories.ts b/packages/racing/infrastructure/repositories/InMemoryScoringRepositories.ts index 150eead77..de2120048 100644 --- a/packages/racing/infrastructure/repositories/InMemoryScoringRepositories.ts +++ b/packages/racing/infrastructure/repositories/InMemoryScoringRepositories.ts @@ -2,17 +2,17 @@ import { Game } from '@gridpilot/racing/domain/entities/Game'; import { Season } from '@gridpilot/racing/domain/entities/Season'; import type { LeagueScoringConfig } from '@gridpilot/racing/domain/entities/LeagueScoringConfig'; import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable'; -import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; -import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType'; -import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; -import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy'; +import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig'; +import type { SessionType } from '@gridpilot/racing/domain/types/SessionType'; +import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule'; +import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy'; import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository'; import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository'; import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository'; import { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding'; -import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType'; -import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef'; +import type { ChampionshipType } from '@gridpilot/racing/domain/types/ChampionshipType'; +import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef'; export type LeagueScoringPresetPrimaryChampionshipType = | 'driver' diff --git a/packages/racing/infrastructure/repositories/InMemoryTeamMembershipRepository.ts b/packages/racing/infrastructure/repositories/InMemoryTeamMembershipRepository.ts index 2d5d5d838..ae6c2434d 100644 --- a/packages/racing/infrastructure/repositories/InMemoryTeamMembershipRepository.ts +++ b/packages/racing/infrastructure/repositories/InMemoryTeamMembershipRepository.ts @@ -8,7 +8,7 @@ import type { TeamMembership, TeamJoinRequest, -} from '@gridpilot/racing/domain/entities/Team'; +} from '@gridpilot/racing/domain/types/TeamMembership'; import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository'; export class InMemoryTeamMembershipRepository implements ITeamMembershipRepository { diff --git a/packages/shared/application/Service.ts b/packages/shared/application/Service.ts new file mode 100644 index 000000000..54c491fc4 --- /dev/null +++ b/packages/shared/application/Service.ts @@ -0,0 +1,18 @@ +import type { Result } from '../result/Result'; +import type { IApplicationError } from '../errors/ApplicationError'; + +export interface IApplicationService { + readonly serviceName?: string; +} + +export interface IAsyncApplicationService extends IApplicationService { + execute(input: Input): Promise; +} + +export interface IAsyncResultApplicationService< + Input, + Output, + Error = IApplicationError +> extends IApplicationService { + execute(input: Input): Promise>; +} \ No newline at end of file diff --git a/packages/shared/application/UseCase.ts b/packages/shared/application/UseCase.ts new file mode 100644 index 000000000..b318a9fc8 --- /dev/null +++ b/packages/shared/application/UseCase.ts @@ -0,0 +1,17 @@ +import { Result } from '../result/Result'; + +export interface IUseCase { + execute(input: Input): Output; +} + +export interface AsyncUseCase { + execute(input: Input): Promise; +} + +export interface ResultUseCase { + execute(input: Input): Result; +} + +export interface AsyncResultUseCase { + execute(input: Input): Promise>; +} \ No newline at end of file diff --git a/packages/shared/application/index.ts b/packages/shared/application/index.ts new file mode 100644 index 000000000..dbda62607 --- /dev/null +++ b/packages/shared/application/index.ts @@ -0,0 +1,2 @@ +export * from './UseCase'; +export * from './Service'; \ No newline at end of file diff --git a/packages/shared/docs/ValueObjectCandidates.md b/packages/shared/docs/ValueObjectCandidates.md new file mode 100644 index 000000000..3026fb58f --- /dev/null +++ b/packages/shared/docs/ValueObjectCandidates.md @@ -0,0 +1,257 @@ +# Value Object Candidates Audit + +This document lists domain concepts currently modeled as primitives or simple types that should be refactored into explicit value objects implementing `IValueObject`. + +Priority levels: +- **High**: Cross-cutting identifiers, URLs, or settings with clear invariants and repeated usage. +- **Medium**: Important within a single bounded context but less cross-cutting. +- **Low**: Niche or rarely used concepts. + +--- + +## Analytics + +### Analytics/PageView + +- **Concept**: `PageViewId` ✅ Implemented + - **Implementation**: [`PageViewId`](packages/analytics/domain/value-objects/PageViewId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:14), [`PageViewId.test`](packages/analytics/domain/value-objects/PageViewId.test.ts) + - **Notes**: Page view identifiers are now modeled as a VO and used internally by the `PageView` entity while repositories and use cases continue to work with primitive string IDs where appropriate. + - **Priority**: High + +- **Concept**: `AnalyticsEntityId` (for analytics) ✅ Implemented + - **Implementation**: [`AnalyticsEntityId`](packages/analytics/domain/value-objects/AnalyticsEntityId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:16), [`AnalyticsSnapshot`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:16), [`EngagementEvent`](packages/analytics/domain/entities/EngagementEvent.ts:15), [`AnalyticsEntityId.test`](packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts) + - **Notes**: Entity IDs within the analytics bounded context are now modeled as a VO and used internally in snapshots, engagement events, and page views; external DTOs still expose primitive strings. + - **Priority**: High + +- **Concept**: `AnalyticsSessionId` ✅ Implemented + - **Implementation**: [`AnalyticsSessionId`](packages/analytics/domain/value-objects/AnalyticsSessionId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:18), [`EngagementEvent`](packages/analytics/domain/entities/EngagementEvent.ts:22), [`AnalyticsSessionId.test`](packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts) + - **Notes**: Session identifiers are now encapsulated in a VO and used internally across analytics entities while preserving primitive session IDs at the boundaries. + - **Priority**: High + +- **Concept**: `ReferrerUrl` + - **Location**: [`PageView.referrer`](packages/analytics/domain/entities/PageView.ts:18), [`PageViewProps.referrer`](packages/analytics/domain/types/PageView.ts:19) + - **Why VO**: External URL with semantics around internal vs external (`isExternalReferral` method). Currently string with no URL parsing or normalization. + - **Priority**: Medium + +- **Concept**: `CountryCode` + - **Location**: [`PageView.country`](packages/analytics/domain/entities/PageView.ts:20), [`PageViewProps.country`](packages/analytics/domain/types/PageView.ts:21) + - **Why VO**: ISO country codes or similar; currently unvalidated string. Could enforce standardized codes. + - **Priority**: Medium + +- **Concept**: `SnapshotId` + - **Location**: [`AnalyticsSnapshot.id`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:16), [`AnalyticsSnapshotProps.id`](packages/analytics/domain/types/AnalyticsSnapshot.ts:27) + - **Why VO**: Identity for time-bucketed analytics snapshots; currently primitive string with simple validation. + - **Priority**: Medium + +- **Concept**: `SnapshotPeriod` (as VO vs string union) + - **Location**: [`SnapshotPeriod`](packages/analytics/domain/types/AnalyticsSnapshot.ts:8), [`AnalyticsSnapshot.period`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:20) + - **Why VO**: Has semantics used in [`getPeriodLabel`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:130); could encapsulate formatting logic and date range constraints. Currently a union type only. + - **Priority**: Low (enum-like, acceptable as-is for now) + +### Analytics/EngagementEvent + +- **Concept**: `EngagementEventId` + - **Location**: [`EngagementEvent.id`](packages/analytics/domain/entities/EngagementEvent.ts:15), [`EngagementEventProps.id`](packages/analytics/domain/types/EngagementEvent.ts:28) + - **Why VO**: Unique ID for engagement events; only non-empty validation today. Could unify ID semantics with other analytics IDs. + - **Priority**: Medium + +- **Concept**: `ActorId` (analytics) + - **Location**: [`EngagementEvent.actorId`](packages/analytics/domain/entities/EngagementEvent.ts:20), [`EngagementEventProps.actorId`](packages/analytics/domain/types/EngagementEvent.ts:32) + - **Why VO**: Identifies the actor (anonymous / driver / sponsor) with a type discriminator; could be a specific `ActorId` VO constrained by `actorType`. + - **Priority**: Low (usage seems optional and less central) + +--- + +## Notifications + +### Notification Entity + +- **Concept**: `NotificationId` ✅ Implemented + - **Implementation**: [`NotificationId`](packages/notifications/domain/value-objects/NotificationId.ts), [`Notification`](packages/notifications/domain/entities/Notification.ts:89), [`NotificationId.test`](packages/notifications/domain/value-objects/NotificationId.test.ts), [`SendNotificationUseCase`](packages/notifications/application/use-cases/SendNotificationUseCase.ts:46) + - **Notes**: Notification aggregate IDs are now modeled as a VO and used internally by the `Notification` entity; repositories and use cases still operate with primitive string IDs via entity factories and serialization. + - **Priority**: High + +- **Concept**: `RecipientId` (NotificationRecipientId) + - **Location**: [`NotificationProps.recipientId`](packages/notifications/domain/entities/Notification.ts:59), [`Notification.recipientId`](packages/notifications/domain/entities/Notification.ts:115) + - **Why VO**: Identity of the driver who receives notifications; likely aligns with identity/user IDs and is important for routing. + - **Priority**: High + +- **Concept**: `ActionUrl` + - **Location**: [`NotificationProps.actionUrl`](packages/notifications/domain/entities/Notification.ts:75), [`Notification.actionUrl`](packages/notifications/domain/entities/Notification.ts:123) + - **Why VO**: URL used for click-through actions in notifications; should be validated/normalized and may have internal vs external semantics. + - **Priority**: High + +- **Concept**: `NotificationActionId` + - **Location**: [`NotificationAction.actionId`](packages/notifications/domain/entities/Notification.ts:53), [`Notification.markAsResponded`](packages/notifications/domain/entities/Notification.ts:182) + - **Why VO**: Identifies action button behavior; currently raw string used to record `responseActionId` in `data`. + - **Priority**: Low + +### NotificationPreference Entity + +- **Concept**: `NotificationPreferenceId` + - **Location**: [`NotificationPreferenceProps.id`](packages/notifications/domain/entities/NotificationPreference.ts:25), [`NotificationPreference.id`](packages/notifications/domain/entities/NotificationPreference.ts:80) + - **Why VO**: Aggregate ID; currently plain string tied to driver ID; could be constrained to match a `DriverId` or similar. + - **Priority**: Medium + +- **Concept**: `PreferenceOwnerId` (driverId) + - **Location**: [`NotificationPreferenceProps.driverId`](packages/notifications/domain/entities/NotificationPreference.ts:28), [`NotificationPreference.driverId`](packages/notifications/domain/entities/NotificationPreference.ts:81) + - **Why VO**: Identifies the driver whose preferences these are; should align with identity/racing driver IDs. + - **Priority**: High + +- **Concept**: `QuietHours` + - **Location**: [`NotificationPreferenceProps.quietHoursStart`](packages/notifications/domain/entities/NotificationPreference.ts:38), [`NotificationPreferenceProps.quietHoursEnd`](packages/notifications/domain/entities/NotificationPreference.ts:40), [`NotificationPreference.isInQuietHours`](packages/notifications/domain/entities/NotificationPreference.ts:125) + - **Why VO**: Encapsulates a time window invariant (0–23, wrap-around support, comparison with current hour); currently implemented as two numbers plus logic in the entity. Ideal VO candidate. + - **Priority**: High + +- **Concept**: `DigestFrequency` + - **Location**: [`NotificationPreferenceProps.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:37), [`NotificationPreference.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:87) + - **Why VO**: Represents cadence for digest emails in hours; could enforce positive ranges and provide helper methods. + - **Priority**: Medium + +--- + +## Media + +### AvatarGenerationRequest + +- **Concept**: `AvatarGenerationRequestId` + - **Location**: [`AvatarGenerationRequest.id`](packages/media/domain/entities/AvatarGenerationRequest.ts:15), [`AvatarGenerationRequestProps.id`](packages/media/domain/types/AvatarGenerationRequest.ts:33) + - **Why VO**: Aggregate ID for avatar generation request lifecycle; currently raw string with only non-empty checks. + - **Priority**: Medium + +- **Concept**: `AvatarOwnerId` (userId) + - **Location**: [`AvatarGenerationRequest.userId`](packages/media/domain/entities/AvatarGenerationRequest.ts:17), [`AvatarGenerationRequestProps.userId`](packages/media/domain/types/AvatarGenerationRequest.ts:34) + - **Why VO**: Identity reference to user; could be tied to `UserId` VO or a dedicated `AvatarOwnerId`. + - **Priority**: Medium + +- **Concept**: `FacePhotoUrl` + - **Location**: [`AvatarGenerationRequest.facePhotoUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:18), [`AvatarGenerationRequestProps.facePhotoUrl`](packages/media/domain/types/AvatarGenerationRequest.ts:35) + - **Why VO**: External URL to user-submitted media; should be validated, normalized, and potentially constrained to HTTPS or whitelisted hosts. + - **Priority**: High + +- **Concept**: `GeneratedAvatarUrl` + - **Location**: [`AvatarGenerationRequest._generatedAvatarUrls`](packages/media/domain/entities/AvatarGenerationRequest.ts:22), [`AvatarGenerationRequestProps.generatedAvatarUrls`](packages/media/domain/types/AvatarGenerationRequest.ts:39), [`AvatarGenerationRequest.selectedAvatarUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:86) + - **Why VO**: Generated asset URLs with invariant that at least one must be present when completed; currently raw strings in an array. + - **Priority**: High + +--- + +## Identity + +### SponsorAccount + +- **Concept**: `SponsorAccountId` + - **Location**: [`SponsorAccountProps.id`](packages/identity/domain/entities/SponsorAccount.ts:12), [`SponsorAccount.getId`](packages/identity/domain/entities/SponsorAccount.ts:73) + - **Status**: Already a VO (`UserId`) – no change needed. + - **Priority**: N/A + +- **Concept**: `SponsorId` (link to racing domain) + - **Location**: [`SponsorAccountProps.sponsorId`](packages/identity/domain/entities/SponsorAccount.ts:14), [`SponsorAccount.getSponsorId`](packages/identity/domain/entities/SponsorAccount.ts:77) + - **Why VO**: Cross-bounded-context reference into racing `Sponsor` entity; currently a primitive string with only non-empty validation. + - **Priority**: High + +- **Concept**: `SponsorAccountEmail` + - **Location**: [`SponsorAccountProps.email`](packages/identity/domain/entities/SponsorAccount.ts:15), [`SponsorAccount.create` email validation](packages/identity/domain/entities/SponsorAccount.ts:60) + - **Status**: Validation uses [`EmailAddress` VO utilities](packages/identity/domain/value-objects/EmailAddress.ts:15) but the entity still stores `email: string`. + - **Why VO**: Entity should likely store `EmailAddress` instead of a plain string to guarantee invariants wherever it is used. + - **Priority**: High + +- **Concept**: `CompanyName` + - **Location**: [`SponsorAccountProps.companyName`](packages/identity/domain/entities/SponsorAccount.ts:17), [`SponsorAccount.getCompanyName`](packages/identity/domain/entities/SponsorAccount.ts:89) + - **Why VO**: Represents sponsor company name with potential invariants (length, prohibited characters). Currently only checked for non-empty. + - **Priority**: Low + +--- + +## Racing + +### League Entity + +- **Concept**: `LeagueId` + - **Location**: [`League.id`](packages/racing/domain/entities/League.ts:83), `validate` ID check in [`League.validate`](packages/racing/domain/entities/League.ts:157) + - **Why VO**: Aggregate root ID; central to many references (races, teams, sponsorships). Currently primitive string with non-empty validation only. + - **Priority**: High + +- **Concept**: `LeagueOwnerId` + - **Location**: [`League.ownerId`](packages/racing/domain/entities/League.ts:87), validation in [`League.validate`](packages/racing/domain/entities/League.ts:179) + - **Why VO**: Identity of league owner; likely maps to a `UserId` or `DriverId` concept; should not remain a free-form string. + - **Priority**: High + +- **Concept**: `LeagueSocialLinkUrl` (`DiscordUrl`, `YoutubeUrl`, `WebsiteUrl`) + - **Location**: [`LeagueSocialLinks.discordUrl`](packages/racing/domain/entities/League.ts:77), [`LeagueSocialLinks.youtubeUrl`](packages/racing/domain/entities/League.ts:79), [`LeagueSocialLinks.websiteUrl`](packages/racing/domain/entities/League.ts:80) + - **Why VO**: External URLs across multiple channels; should be validated and normalized; repeated semantics across UI and domain. + - **Priority**: High + +### Track Entity + +- **Concept**: `TrackId` + - **Location**: [`Track.id`](packages/racing/domain/entities/Track.ts:14), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:92) + - **Why VO**: Aggregate root ID for tracks; referenced from races and schedules; currently primitive string. + - **Priority**: High + +- **Concept**: `TrackCountryCode` + - **Location**: [`Track.country`](packages/racing/domain/entities/Track.ts:18), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:100) + - **Why VO**: Represent country using standard codes; currently a free-form string. + - **Priority**: Medium + +- **Concept**: `TrackImageUrl` + - **Location**: [`Track.imageUrl`](packages/racing/domain/entities/Track.ts:23) + - **Why VO**: Image asset URL; should be constrained and validated similarly to other URL concepts. + - **Priority**: High + +- **Concept**: `GameId` + - **Location**: [`Track.gameId`](packages/racing/domain/entities/Track.ts:24), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:112) + - **Why VO**: Identifier for simulation/game platform; currently string with non-empty validation; may benefit from VO if multiple entities use it. + - **Priority**: Medium + +### Race Entity + +- **Concept**: `RaceId` + - **Location**: [`Race.id`](packages/racing/domain/entities/Race.ts:14), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:101) + - **Why VO**: Aggregate ID for races; central to many operations and references. + - **Priority**: High + +- **Concept**: `RaceLeagueId` + - **Location**: [`Race.leagueId`](packages/racing/domain/entities/Race.ts:16), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:105) + - **Why VO**: Foreign key into `League`; should be modeled as `LeagueId` VO rather than raw string. + - **Priority**: High + +- **Concept**: `RaceTrackId` / `RaceCarId` + - **Location**: [`Race.trackId`](packages/racing/domain/entities/Race.ts:19), [`Race.carId`](packages/racing/domain/entities/Race.ts:21) + - **Why VO**: Optional references to track and car entities; currently strings; could be typed IDs aligned with `TrackId` and car ID concepts. + - **Priority**: Medium + +- **Concept**: `RaceName` / `TrackName` / `CarName` + - **Location**: [`Race.track`](packages/racing/domain/entities/Race.ts:18), [`Race.car`](packages/racing/domain/entities/Race.ts:20) + - **Why VO**: Displayable names with potential formatting rules; today treated as raw strings, which is acceptable for now. + - **Priority**: Low + +### Team Entity + +- **Concept**: `TeamId` + - **Location**: [`Team.id`](packages/racing/domain/entities/Team.ts:12), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:108) + - **Why VO**: Aggregate ID; referenced from standings, registrations, etc. Currently primitive. + - **Priority**: High + +- **Concept**: `TeamOwnerId` + - **Location**: [`Team.ownerId`](packages/racing/domain/entities/Team.ts:17), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:120) + - **Why VO**: Identity of team owner; should map to `UserId` or `DriverId`, currently a simple string. + - **Priority**: High + +- **Concept**: `TeamLeagueId` (for membership list) + - **Location**: [`Team.leagues`](packages/racing/domain/entities/Team.ts:18), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:124) + - **Why VO**: Array of league IDs; currently `string[]` with no per-item validation; could leverage `LeagueId` VO and a small collection abstraction. + - **Priority**: Medium + +--- + +## Summary of Highest-Impact Candidates (Not Yet Refactored) + +The following are **high-priority** candidates that have not been refactored in this pass but are strong future VO targets: + +- `LeagueId`, `RaceId`, `TeamId`, and their foreign key counterparts (`RaceLeagueId`, `RaceTrackId`, `RaceCarId`, `TeamLeagueId`). +- Cross-bounded-context identifiers: `SponsorId` in identity linking to racing `Sponsor`, `PreferenceOwnerId` / `NotificationPreferenceId` in notifications, and remaining analytics/session identifiers where primitive usage persists across boundaries. +- URL-related concepts beyond those refactored in this pass: `LeagueSocialLinkUrl` variants, `TrackImageUrl`, `ReferrerUrl`, `ActionUrl` in notifications, and avatar-related URLs in media (where not yet wrapped). +- Time-window and scheduling primitives: `QuietHours` numeric start/end in notifications, and other time-related raw numbers in stewarding settings and session configuration where richer semantics may help. + +These should be considered for future VO-focused refactors once the impact on mappers, repositories, and application layers is planned and coordinated. \ No newline at end of file diff --git a/packages/shared/domain/Entity.ts b/packages/shared/domain/Entity.ts new file mode 100644 index 000000000..c507fbd5e --- /dev/null +++ b/packages/shared/domain/Entity.ts @@ -0,0 +1,3 @@ +export interface IEntity { + readonly id: Id; +} \ No newline at end of file diff --git a/packages/shared/domain/Service.ts b/packages/shared/domain/Service.ts new file mode 100644 index 000000000..7d75e1967 --- /dev/null +++ b/packages/shared/domain/Service.ts @@ -0,0 +1,24 @@ +import type { Result } from '../result/Result'; +import type { IDomainError } from '../errors/DomainError'; + +export interface IDomainService { + readonly serviceName?: string; +} + +export interface IDomainCalculationService extends IDomainService { + calculate(input: Input): Output; +} + +export interface IResultDomainCalculationService + extends IDomainService { + calculate(input: Input): Result; +} + +export interface IDomainValidationService + extends IDomainService { + validate(input: Input): Result; +} + +export interface IDomainFactoryService extends IDomainService { + create(input: Input): Output; +} \ No newline at end of file diff --git a/packages/shared/domain/ValueObject.ts b/packages/shared/domain/ValueObject.ts new file mode 100644 index 000000000..2ba8cd952 --- /dev/null +++ b/packages/shared/domain/ValueObject.ts @@ -0,0 +1,4 @@ +export interface IValueObject { + readonly props: Props; + equals(other: IValueObject): boolean; +} \ No newline at end of file diff --git a/packages/shared/domain/index.ts b/packages/shared/domain/index.ts new file mode 100644 index 000000000..5965dea3c --- /dev/null +++ b/packages/shared/domain/index.ts @@ -0,0 +1,3 @@ +export * from './Entity'; +export * from './ValueObject'; +export * from './Service'; \ No newline at end of file diff --git a/packages/shared/errors/ApplicationError.ts b/packages/shared/errors/ApplicationError.ts new file mode 100644 index 000000000..c646b5f51 --- /dev/null +++ b/packages/shared/errors/ApplicationError.ts @@ -0,0 +1,14 @@ +export type CommonApplicationErrorKind = + | 'not_found' + | 'forbidden' + | 'conflict' + | 'validation' + | 'unknown' + | string; + +export interface IApplicationError extends Error { + readonly type: 'application'; + readonly context: string; + readonly kind: K; + readonly details?: D; +} \ No newline at end of file diff --git a/packages/shared/errors/DomainError.ts b/packages/shared/errors/DomainError.ts new file mode 100644 index 000000000..899412b7c --- /dev/null +++ b/packages/shared/errors/DomainError.ts @@ -0,0 +1,7 @@ +export type CommonDomainErrorKind = 'validation' | 'invariant' | string; + +export interface IDomainError extends Error { + readonly type: 'domain'; + readonly context: string; + readonly kind: K; +} \ No newline at end of file diff --git a/packages/shared/errors/index.ts b/packages/shared/errors/index.ts new file mode 100644 index 000000000..248af5e9e --- /dev/null +++ b/packages/shared/errors/index.ts @@ -0,0 +1,2 @@ +export * from './DomainError'; +export * from './ApplicationError'; \ No newline at end of file diff --git a/packages/shared/index.ts b/packages/shared/index.ts new file mode 100644 index 000000000..8398a21b1 --- /dev/null +++ b/packages/shared/index.ts @@ -0,0 +1,4 @@ +export * from './result/Result'; +export * as application from './application'; +export * as domain from './domain'; +export * as errors from './errors'; \ No newline at end of file diff --git a/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts b/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts index b7692da6d..53dfd8b47 100644 --- a/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts +++ b/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts @@ -1,3 +1,4 @@ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository'; import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO'; import type { FriendDTO } from '../dto/FriendDTO'; @@ -16,7 +17,8 @@ export interface GetCurrentUserSocialParams { * Keeps orchestration in the social bounded context while delegating * data access to domain repositories and presenting via a presenter. */ -export class GetCurrentUserSocialUseCase { +export class GetCurrentUserSocialUseCase + implements AsyncUseCase { constructor( private readonly socialGraphRepository: ISocialGraphRepository, public readonly presenter: ICurrentUserSocialPresenter, diff --git a/packages/social/application/use-cases/GetUserFeedUseCase.ts b/packages/social/application/use-cases/GetUserFeedUseCase.ts index 065259822..216a845aa 100644 --- a/packages/social/application/use-cases/GetUserFeedUseCase.ts +++ b/packages/social/application/use-cases/GetUserFeedUseCase.ts @@ -1,6 +1,7 @@ +import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { IFeedRepository } from '../../domain/repositories/IFeedRepository'; import type { FeedItemDTO } from '../dto/FeedItemDTO'; -import type { FeedItem } from '../../domain/entities/FeedItem'; +import type { FeedItem } from '../../domain/types/FeedItem'; import type { IUserFeedPresenter, UserFeedViewModel, @@ -11,7 +12,8 @@ export interface GetUserFeedParams { limit?: number; } -export class GetUserFeedUseCase { +export class GetUserFeedUseCase + implements AsyncUseCase { constructor( private readonly feedRepository: IFeedRepository, public readonly presenter: IUserFeedPresenter, diff --git a/packages/social/domain/errors/SocialDomainError.ts b/packages/social/domain/errors/SocialDomainError.ts index 6917c4cc0..4797c4f9c 100644 --- a/packages/social/domain/errors/SocialDomainError.ts +++ b/packages/social/domain/errors/SocialDomainError.ts @@ -1,8 +1,19 @@ -export class SocialDomainError extends Error { - readonly name: string = 'SocialDomainError'; +import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors'; - constructor(message: string) { +/** + * Domain Error: SocialDomainError + * + * Implements the shared IDomainError contract for social domain failures. + */ +export class SocialDomainError extends Error implements IDomainError { + readonly name = 'SocialDomainError'; + readonly type = 'domain' as const; + readonly context = 'social'; + readonly kind: CommonDomainErrorKind; + + constructor(message: string, kind: CommonDomainErrorKind = 'validation') { super(message); + this.kind = kind; Object.setPrototypeOf(this, new.target.prototype); } } \ No newline at end of file diff --git a/packages/social/domain/repositories/IFeedRepository.ts b/packages/social/domain/repositories/IFeedRepository.ts index 8bdb5d34c..8f222a3b7 100644 --- a/packages/social/domain/repositories/IFeedRepository.ts +++ b/packages/social/domain/repositories/IFeedRepository.ts @@ -1,4 +1,4 @@ -import type { FeedItem } from '../entities/FeedItem'; +import type { FeedItem } from '../types/FeedItem'; export interface IFeedRepository { getFeedForDriver(driverId: string, limit?: number): Promise; diff --git a/packages/social/domain/entities/FeedItem.ts b/packages/social/domain/types/FeedItem.ts similarity index 56% rename from packages/social/domain/entities/FeedItem.ts rename to packages/social/domain/types/FeedItem.ts index be78e7641..381b73d2a 100644 --- a/packages/social/domain/entities/FeedItem.ts +++ b/packages/social/domain/types/FeedItem.ts @@ -1,5 +1,11 @@ -import type { FeedItemType } from '../value-objects/FeedItemType'; +import type { FeedItemType } from './FeedItemType'; +/** + * Domain Type: FeedItem + * + * Pure feed item shape used by repositories and application DTO mappers. + * This is not a domain entity (no identity/behavior beyond data). + */ export interface FeedItem { id: string; timestamp: Date; diff --git a/packages/social/domain/types/FeedItemType.ts b/packages/social/domain/types/FeedItemType.ts new file mode 100644 index 000000000..607620206 --- /dev/null +++ b/packages/social/domain/types/FeedItemType.ts @@ -0,0 +1,15 @@ +/** + * Domain Type: FeedItemType + * + * Union representing the kinds of items that can appear in a user's social feed. + * This is a pure type and therefore belongs in domain/types rather than + * domain/value-objects, which is reserved for class-based value objects. + */ +export type FeedItemType = + | 'friend-joined-league' + | 'friend-joined-team' + | 'friend-finished-race' + | 'friend-new-personal-best' + | 'new-race-scheduled' + | 'new-result-posted' + | 'league-highlight'; \ No newline at end of file diff --git a/packages/social/domain/value-objects/FeedItemType.ts b/packages/social/domain/value-objects/FeedItemType.ts deleted file mode 100644 index 809ad873a..000000000 --- a/packages/social/domain/value-objects/FeedItemType.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type FeedItemType = - | 'friend-joined-league' - | 'friend-joined-team' - | 'friend-finished-race' - | 'friend-new-personal-best' - | 'new-race-scheduled' - | 'new-result-posted' - | 'league-highlight'; \ No newline at end of file diff --git a/packages/social/infrastructure/inmemory/InMemorySocialAndFeed.ts b/packages/social/infrastructure/inmemory/InMemorySocialAndFeed.ts index 95f630d44..b7d4c97cf 100644 --- a/packages/social/infrastructure/inmemory/InMemorySocialAndFeed.ts +++ b/packages/social/infrastructure/inmemory/InMemorySocialAndFeed.ts @@ -1,5 +1,5 @@ import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; -import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem'; +import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem'; import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository'; import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository'; diff --git a/packages/testing-support/src/racing/RacingFeedSeed.ts b/packages/testing-support/src/racing/RacingFeedSeed.ts index d8f251c51..98d97b5a0 100644 --- a/packages/testing-support/src/racing/RacingFeedSeed.ts +++ b/packages/testing-support/src/racing/RacingFeedSeed.ts @@ -2,7 +2,7 @@ import { Driver } from '@gridpilot/racing/domain/entities/Driver'; import { League } from '@gridpilot/racing/domain/entities/League'; import { Race } from '@gridpilot/racing/domain/entities/Race'; import type { Result } from '@gridpilot/racing/domain/entities/Result'; -import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem'; +import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem'; import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO'; import { faker } from '../faker/faker'; import { getLeagueBanner, getDriverAvatar } from '../images/images'; diff --git a/packages/testing-support/src/racing/RacingStaticSeed.ts b/packages/testing-support/src/racing/RacingStaticSeed.ts index 3055288a3..dc00f28a8 100644 --- a/packages/testing-support/src/racing/RacingStaticSeed.ts +++ b/packages/testing-support/src/racing/RacingStaticSeed.ts @@ -4,7 +4,7 @@ import { Race } from '@gridpilot/racing/domain/entities/Race'; import { Result } from '@gridpilot/racing/domain/entities/Result'; import { Standing } from '@gridpilot/racing/domain/entities/Standing'; -import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem'; +import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem'; import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO'; import { faker } from '../faker/faker'; diff --git a/tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts b/tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts index 9cc346688..77111e1a3 100644 --- a/tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts +++ b/tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { DIContainer } from '../../../apps/companion/main/di-container'; import { StepId } from '@gridpilot/automation/domain/value-objects/StepId'; -import type { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '@gridpilot/automation/domain/types/HostedSessionConfig'; import { PlaywrightAutomationAdapter } from 'packages/automation/infrastructure/adapters/automation'; describe('Companion UI - hosted workflow via fixture-backed real stack', () => { diff --git a/tests/integration/interface/companion/companion-start-automation.browser-mode-refresh.integration.test.ts b/tests/integration/interface/companion/companion-start-automation.browser-mode-refresh.integration.test.ts index a8ebc8d28..9d4c235d4 100644 --- a/tests/integration/interface/companion/companion-start-automation.browser-mode-refresh.integration.test.ts +++ b/tests/integration/interface/companion/companion-start-automation.browser-mode-refresh.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { DIContainer } from '../../../..//apps/companion/main/di-container'; -import type { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '@gridpilot/automation/domain/types/HostedSessionConfig'; import { StepId } from '@gridpilot/automation/domain/value-objects/StepId'; import { PlaywrightAutomationAdapter } from '../../../..//packages/automation/infrastructure/adapters/automation'; diff --git a/tests/integration/interface/companion/companion-start-automation.browser-not-connected.integration.test.ts b/tests/integration/interface/companion/companion-start-automation.browser-not-connected.integration.test.ts index 1b1c75183..7b3afd3f9 100644 --- a/tests/integration/interface/companion/companion-start-automation.browser-not-connected.integration.test.ts +++ b/tests/integration/interface/companion/companion-start-automation.browser-not-connected.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { DIContainer } from '../../../..//apps/companion/main/di-container'; -import type { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '@gridpilot/automation/domain/types/HostedSessionConfig'; import { StepId } from '@gridpilot/automation/domain/value-objects/StepId'; import { PlaywrightAutomationAdapter } from '../../../..//packages/automation/infrastructure/adapters/automation'; diff --git a/tests/integration/interface/companion/companion-start-automation.connection-failure.integration.test.ts b/tests/integration/interface/companion/companion-start-automation.connection-failure.integration.test.ts index cfe551a74..6c4289fa7 100644 --- a/tests/integration/interface/companion/companion-start-automation.connection-failure.integration.test.ts +++ b/tests/integration/interface/companion/companion-start-automation.connection-failure.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { DIContainer } from '../../../..//apps/companion/main/di-container'; -import type { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '@gridpilot/automation/domain/types/HostedSessionConfig'; import { PlaywrightAutomationAdapter } from '../../../..//packages/automation/infrastructure/adapters/automation'; describe('companion start automation - browser connection failure before steps', () => { diff --git a/tests/integration/interface/companion/companion-start-automation.happy.integration.test.ts b/tests/integration/interface/companion/companion-start-automation.happy.integration.test.ts index 1ae71561b..853ba7e81 100644 --- a/tests/integration/interface/companion/companion-start-automation.happy.integration.test.ts +++ b/tests/integration/interface/companion/companion-start-automation.happy.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { DIContainer } from '../../../..//apps/companion/main/di-container'; -import type { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig'; +import type { HostedSessionConfig } from '@gridpilot/automation/domain/types/HostedSessionConfig'; import { StepId } from '@gridpilot/automation/domain/value-objects/StepId'; describe('companion start automation - happy path', () => { diff --git a/tests/unit/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts b/tests/unit/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts index 6994049a6..5376a428b 100644 --- a/tests/unit/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts +++ b/tests/unit/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts @@ -14,14 +14,14 @@ import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { Result } from '@gridpilot/racing/domain/entities/Result'; import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding'; -import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; +import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig'; import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService'; import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier'; import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator'; import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable'; -import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType'; -import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; -import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy'; +import type { SessionType } from '@gridpilot/racing/domain/types/SessionType'; +import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule'; +import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy'; class InMemorySeasonRepository implements ISeasonRepository { private seasons: Season[] = []; diff --git a/tests/unit/domain/services/DropScoreApplier.test.ts b/tests/unit/domain/services/DropScoreApplier.test.ts index 977bd8670..e801aee96 100644 --- a/tests/unit/domain/services/DropScoreApplier.test.ts +++ b/tests/unit/domain/services/DropScoreApplier.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier'; import type { EventPointsEntry } from '@gridpilot/racing/domain/services/DropScoreApplier'; -import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy'; +import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy'; describe('DropScoreApplier', () => { it('with strategy none counts all events and drops none', () => { diff --git a/tests/unit/domain/services/EventScoringService.test.ts b/tests/unit/domain/services/EventScoringService.test.ts index c4744031c..86e4a2990 100644 --- a/tests/unit/domain/services/EventScoringService.test.ts +++ b/tests/unit/domain/services/EventScoringService.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService'; -import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef'; -import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType'; +import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef'; +import type { SessionType } from '@gridpilot/racing/domain/types/SessionType'; import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable'; -import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; -import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; +import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule'; +import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig'; import type { Result } from '@gridpilot/racing/domain/entities/Result'; import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType'; diff --git a/tests/unit/domain/services/ScheduleCalculator.test.ts b/tests/unit/domain/services/ScheduleCalculator.test.ts new file mode 100644 index 000000000..516950012 --- /dev/null +++ b/tests/unit/domain/services/ScheduleCalculator.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from '../../../../packages/racing/domain/services/ScheduleCalculator'; +import type { Weekday } from '../../../../packages/racing/domain/types/Weekday'; + +describe('ScheduleCalculator', () => { + describe('calculateRaceDates', () => { + describe('with empty or invalid input', () => { + it('should return empty array when weekdays is empty', () => { + // Given + const config: ScheduleConfig = { + weekdays: [], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-01'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates).toEqual([]); + expect(result.seasonDurationWeeks).toBe(0); + }); + + it('should return empty array when rounds is 0', () => { + // Given + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 0, + startDate: new Date('2024-01-01'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates).toEqual([]); + }); + + it('should return empty array when rounds is negative', () => { + // Given + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: -5, + startDate: new Date('2024-01-01'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates).toEqual([]); + }); + }); + + describe('weekly scheduling', () => { + it('should schedule 8 races on Saturdays starting from a Saturday', () => { + // Given - January 6, 2024 is a Saturday + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-06'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(8); + // All dates should be Saturdays + result.raceDates.forEach(date => { + expect(date.getDay()).toBe(6); // Saturday + }); + // First race should be Jan 6 + expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06'); + // Last race should be 7 weeks later (Feb 24) + expect(result.raceDates[7].toISOString().split('T')[0]).toBe('2024-02-24'); + }); + + it('should schedule races on multiple weekdays', () => { + // Given + const config: ScheduleConfig = { + weekdays: ['Wed', 'Sat'] as Weekday[], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-01'), // Monday + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(8); + // Should alternate between Wednesday and Saturday + result.raceDates.forEach(date => { + const day = date.getDay(); + expect([3, 6]).toContain(day); // Wed=3, Sat=6 + }); + }); + + it('should schedule 8 races on Sundays', () => { + // Given - January 7, 2024 is a Sunday + const config: ScheduleConfig = { + weekdays: ['Sun'] as Weekday[], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-01'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(8); + result.raceDates.forEach(date => { + expect(date.getDay()).toBe(0); // Sunday + }); + }); + }); + + describe('bi-weekly scheduling', () => { + it('should schedule races every 2 weeks on Saturdays', () => { + // Given - January 6, 2024 is a Saturday + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'everyNWeeks', + rounds: 4, + startDate: new Date('2024-01-06'), + intervalWeeks: 2, + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(4); + // First race Jan 6 + expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06'); + // Second race 2 weeks later (Jan 20) + expect(result.raceDates[1].toISOString().split('T')[0]).toBe('2024-01-20'); + // Third race 2 weeks later (Feb 3) + expect(result.raceDates[2].toISOString().split('T')[0]).toBe('2024-02-03'); + // Fourth race 2 weeks later (Feb 17) + expect(result.raceDates[3].toISOString().split('T')[0]).toBe('2024-02-17'); + }); + }); + + describe('with start and end dates', () => { + it('should evenly distribute races across the date range', () => { + // Given - 3 month season + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-06'), + endDate: new Date('2024-03-30'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(8); + // First race should be at or near start + expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06'); + // Races should be spread across the range, not consecutive weeks + }); + + it('should use all available days if fewer than rounds requested', () => { + // Given - short period with only 3 Saturdays + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 10, + startDate: new Date('2024-01-06'), + endDate: new Date('2024-01-21'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + // Only 3 Saturdays in this range: Jan 6, 13, 20 + expect(result.raceDates.length).toBe(3); + }); + }); + + describe('season duration calculation', () => { + it('should calculate correct season duration in weeks', () => { + // Given + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 8, + startDate: new Date('2024-01-06'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + // 8 races, 1 week apart = 7 weeks duration + expect(result.seasonDurationWeeks).toBe(7); + }); + + it('should return 0 duration for single race', () => { + // Given + const config: ScheduleConfig = { + weekdays: ['Sat'] as Weekday[], + frequency: 'weekly', + rounds: 1, + startDate: new Date('2024-01-06'), + }; + + // When + const result = calculateRaceDates(config); + + // Then + expect(result.raceDates.length).toBe(1); + expect(result.seasonDurationWeeks).toBe(0); + }); + }); + }); + + describe('getNextWeekday', () => { + it('should return next Saturday from a Monday', () => { + // Given - January 1, 2024 is a Monday + const fromDate = new Date('2024-01-01'); + + // When + const result = getNextWeekday(fromDate, 'Sat'); + + // Then + expect(result.toISOString().split('T')[0]).toBe('2024-01-06'); + expect(result.getDay()).toBe(6); + }); + + it('should return next occurrence when already on that weekday', () => { + // Given - January 6, 2024 is a Saturday + const fromDate = new Date('2024-01-06'); + + // When + const result = getNextWeekday(fromDate, 'Sat'); + + // Then + // Should return NEXT Saturday (7 days later), not same day + expect(result.toISOString().split('T')[0]).toBe('2024-01-13'); + }); + + it('should return next Sunday from a Friday', () => { + // Given - January 5, 2024 is a Friday + const fromDate = new Date('2024-01-05'); + + // When + const result = getNextWeekday(fromDate, 'Sun'); + + // Then + expect(result.toISOString().split('T')[0]).toBe('2024-01-07'); + expect(result.getDay()).toBe(0); + }); + + it('should return next Wednesday from a Thursday', () => { + // Given - January 4, 2024 is a Thursday + const fromDate = new Date('2024-01-04'); + + // When + const result = getNextWeekday(fromDate, 'Wed'); + + // Then + // Next Wednesday is 6 days later + expect(result.toISOString().split('T')[0]).toBe('2024-01-10'); + expect(result.getDay()).toBe(3); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/racing-application/RegistrationAndTeamUseCases.test.ts b/tests/unit/racing-application/RegistrationAndTeamUseCases.test.ts index 2ec3593fd..19a07318f 100644 --- a/tests/unit/racing-application/RegistrationAndTeamUseCases.test.ts +++ b/tests/unit/racing-application/RegistrationAndTeamUseCases.test.ts @@ -5,9 +5,9 @@ import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repos import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository'; import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration'; -import type { +import { LeagueMembership, - MembershipStatus, + type MembershipStatus, } from '@gridpilot/racing/domain/entities/LeagueMembership'; import type { Team, @@ -102,7 +102,7 @@ class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembe async getMembership(leagueId: string, driverId: string): Promise { return ( this.memberships.find( - (m) => m.leagueId === leagueId && m.driverId === driverId, + (m) => m.leagueId === leagueId && m.leagueId === leagueId && m.driverId === driverId, ) || null ); } @@ -135,13 +135,15 @@ class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembe } seedActiveMembership(leagueId: string, driverId: string): void { - this.memberships.push({ - leagueId, - driverId, - role: 'member', - status: 'active' as MembershipStatus, - joinedAt: new Date('2024-01-01'), - }); + this.memberships.push( + LeagueMembership.create({ + leagueId, + driverId, + role: 'member', + status: 'active' as MembershipStatus, + joinedAt: new Date('2024-01-01'), + }), + ); } } diff --git a/tsconfig.json b/tsconfig.json index 23a5f86bd..0c7d76a73 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,10 @@ "packages/*": ["packages/*"], "apps/*": ["apps/*"], "@gridpilot/shared-result": ["packages/shared/result/Result.ts"], + "@gridpilot/shared": ["packages/shared/index.ts"], + "@gridpilot/shared/application": ["packages/shared/application"], + "@gridpilot/shared/domain": ["packages/shared/domain"], + "@gridpilot/shared/errors": ["packages/shared/errors"], "@gridpilot/automation/*": ["packages/automation/*"], "@gridpilot/testing-support": ["packages/testing-support/index.ts"], "@gridpilot/media": ["packages/media/index.ts"]