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

View File

@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'; 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'; import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
export interface AuthStatusEvent { export interface AuthStatusEvent {

View File

@@ -198,7 +198,7 @@ function createDefaultForm(): LeagueConfigFormModel {
sessionCount: 2, sessionCount: 2,
roundsPlanned: 8, roundsPlanned: 8,
// Default to Saturday races, weekly, starting next week // 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, recurrenceStrategy: 'weekly' as const,
raceStartTime: '20:00', raceStartTime: '20:00',
timezoneId: 'UTC', timezoneId: 'UTC',

View File

@@ -23,7 +23,7 @@ import type {
LeagueConfigFormModel, LeagueConfigFormModel,
LeagueSchedulePreviewDTO, LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application'; } 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 Input from '@/components/ui/Input';
import RangeField from '@/components/ui/RangeField'; import RangeField from '@/components/ui/RangeField';

View File

@@ -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 { import type {
ILeagueFullConfigPresenter, ILeagueFullConfigPresenter,
LeagueFullConfigData, LeagueFullConfigData,

View File

@@ -1,5 +1,5 @@
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig';
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule';
import type { import type {
ILeagueScoringConfigPresenter, ILeagueScoringConfigPresenter,
LeagueScoringConfigData, LeagueScoringConfigData,

View File

@@ -1,15 +1,16 @@
/** /**
* Query: GetEntityAnalyticsQuery * Query: GetEntityAnalyticsQuery
* *
* Retrieves analytics data for an entity (league, driver, team, race). * Retrieves analytics data for an entity (league, driver, team, race).
* Returns metrics formatted for display to sponsors and admins. * 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 { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository'; import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
import type { EntityType } from '../../domain/entities/PageView'; import type { EntityType } from '../../domain/types/PageView';
import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot'; import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
export interface GetEntityAnalyticsInput { export interface GetEntityAnalyticsInput {
entityType: EntityType; entityType: EntityType;
@@ -41,7 +42,8 @@ export interface EntityAnalyticsOutput {
}; };
} }
export class GetEntityAnalyticsQuery { export class GetEntityAnalyticsQuery
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
constructor( constructor(
private readonly pageViewRepository: IPageViewRepository, private readonly pageViewRepository: IPageViewRepository,
private readonly engagementRepository: IEngagementRepository, private readonly engagementRepository: IEngagementRepository,

View File

@@ -1,9 +1,10 @@
/** /**
* Use Case: RecordEngagementUseCase * Use Case: RecordEngagementUseCase
* *
* Records an engagement event when a visitor interacts with an entity. * 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 { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
@@ -22,7 +23,8 @@ export interface RecordEngagementOutput {
engagementWeight: number; engagementWeight: number;
} }
export class RecordEngagementUseCase { export class RecordEngagementUseCase
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
constructor(private readonly engagementRepository: IEngagementRepository) {} constructor(private readonly engagementRepository: IEngagementRepository) {}
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> { async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {

View File

@@ -1,10 +1,12 @@
/** /**
* Use Case: RecordPageViewUseCase * Use Case: RecordPageViewUseCase
* *
* Records a page view event when a visitor accesses an entity page. * 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'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
export interface RecordPageViewInput { export interface RecordPageViewInput {
@@ -22,7 +24,8 @@ export interface RecordPageViewOutput {
pageViewId: string; pageViewId: string;
} }
export class RecordPageViewUseCase { export class RecordPageViewUseCase
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
constructor(private readonly pageViewRepository: IPageViewRepository) {} constructor(private readonly pageViewRepository: IPageViewRepository) {}
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> { async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {

View File

@@ -1,52 +1,34 @@
/** /**
* Domain Entity: AnalyticsSnapshot * Domain Entity: AnalyticsSnapshot
* *
* Aggregated analytics data for a specific entity over a time period. * Aggregated analytics data for a specific entity over a time period.
* Pre-calculated metrics for sponsor dashboard and entity analytics. * Pre-calculated metrics for sponsor dashboard and entity analytics.
*/ */
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly'; import type { IEntity } from '@gridpilot/shared/domain';
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; import type {
AnalyticsSnapshotProps,
AnalyticsMetrics,
SnapshotEntityType,
SnapshotPeriod,
} from '../types/AnalyticsSnapshot';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export interface AnalyticsMetrics { export class AnalyticsSnapshot implements IEntity<string> {
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 {
readonly id: string; readonly id: string;
readonly entityType: SnapshotEntityType; readonly entityType: SnapshotEntityType;
readonly entityId: string;
readonly period: SnapshotPeriod; readonly period: SnapshotPeriod;
readonly startDate: Date; readonly startDate: Date;
readonly endDate: Date; readonly endDate: Date;
readonly metrics: AnalyticsMetrics; readonly metrics: AnalyticsMetrics;
readonly createdAt: Date; readonly createdAt: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: AnalyticsSnapshotProps) { private constructor(props: AnalyticsSnapshotProps) {
this.id = props.id; this.id = props.id;
this.entityType = props.entityType; this.entityType = props.entityType;
this.entityId = props.entityId; this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.period = props.period; this.period = props.period;
this.startDate = props.startDate; this.startDate = props.startDate;
this.endDate = props.endDate; this.endDate = props.endDate;
@@ -54,6 +36,10 @@ export class AnalyticsSnapshot {
this.createdAt = props.createdAt; this.createdAt = props.createdAt;
} }
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot { static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
this.validate(props); this.validate(props);

View File

@@ -1,51 +1,35 @@
/** /**
* Domain Entity: EngagementEvent * Domain Entity: EngagementEvent
* *
* Represents user interactions beyond page views. * Represents user interactions beyond page views.
* Tracks clicks, downloads, sign-ups, and other engagement actions. * Tracks clicks, downloads, sign-ups, and other engagement actions.
*/ */
export type EngagementAction = import type { IEntity } from '@gridpilot/shared/domain';
| 'click_sponsor_logo' import type {
| 'click_sponsor_url' EngagementAction,
| 'download_livery_pack' EngagementEntityType,
| 'join_league' EngagementEventProps,
| 'register_race' } from '../types/EngagementEvent';
| 'view_standings' import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
| 'view_schedule'
| 'share_social'
| 'contact_sponsor';
export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship'; export class EngagementEvent implements IEntity<string> {
export interface EngagementEventProps {
id: string;
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
timestamp: Date;
}
export class EngagementEvent {
readonly id: string; readonly id: string;
readonly action: EngagementAction; readonly action: EngagementAction;
readonly entityType: EngagementEntityType; readonly entityType: EngagementEntityType;
readonly entityId: string;
readonly actorId?: string; readonly actorId?: string;
readonly actorType: 'anonymous' | 'driver' | 'sponsor'; readonly actorType: 'anonymous' | 'driver' | 'sponsor';
readonly sessionId: string; readonly sessionId: string;
readonly metadata?: Record<string, string | number | boolean>; readonly metadata?: Record<string, string | number | boolean>;
readonly timestamp: Date; readonly timestamp: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: EngagementEventProps) { private constructor(props: EngagementEventProps) {
this.id = props.id; this.id = props.id;
this.action = props.action; this.action = props.action;
this.entityType = props.entityType; this.entityType = props.entityType;
this.entityId = props.entityId; this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.actorId = props.actorId; this.actorId = props.actorId;
this.actorType = props.actorType; this.actorType = props.actorType;
this.sessionId = props.sessionId; this.sessionId = props.sessionId;
@@ -53,6 +37,10 @@ export class EngagementEvent {
this.timestamp = props.timestamp; this.timestamp = props.timestamp;
} }
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent { static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
this.validate(props); this.validate(props);

View File

@@ -1,47 +1,37 @@
/** /**
* Domain Entity: PageView * Domain Entity: PageView
* *
* Represents a single page view event for analytics tracking. * Represents a single page view event for analytics tracking.
* Captures visitor interactions with leagues, drivers, teams, races. * Captures visitor interactions with leagues, drivers, teams, races.
*/ */
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; import type { IEntity } from '@gridpilot/shared/domain';
export type VisitorType = 'anonymous' | 'driver' | 'sponsor'; 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 { export class PageView implements IEntity<string> {
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;
readonly entityType: EntityType; readonly entityType: EntityType;
readonly entityId: string;
readonly visitorId?: string; readonly visitorId?: string;
readonly visitorType: VisitorType; readonly visitorType: VisitorType;
readonly sessionId: string;
readonly referrer?: string; readonly referrer?: string;
readonly userAgent?: string; readonly userAgent?: string;
readonly country?: string; readonly country?: string;
readonly timestamp: Date; readonly timestamp: Date;
readonly durationMs?: number; readonly durationMs?: number;
private readonly idVo: PageViewId;
private readonly entityIdVo: AnalyticsEntityId;
private readonly sessionIdVo: AnalyticsSessionId;
private constructor(props: PageViewProps) { private constructor(props: PageViewProps) {
this.id = props.id; this.idVo = PageViewId.create(props.id);
this.entityType = props.entityType; this.entityType = props.entityType;
this.entityId = props.entityId; this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.visitorId = props.visitorId; this.visitorId = props.visitorId;
this.visitorType = props.visitorType; this.visitorType = props.visitorType;
this.sessionId = props.sessionId; this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
this.referrer = props.referrer; this.referrer = props.referrer;
this.userAgent = props.userAgent; this.userAgent = props.userAgent;
this.country = props.country; this.country = props.country;
@@ -49,6 +39,18 @@ export class PageView {
this.durationMs = props.durationMs; 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<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView { static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
this.validate(props); this.validate(props);

View File

@@ -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;
}

View File

@@ -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<string, string | number | boolean>;
timestamp: Date;
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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<AnalyticsEntityIdProps> {
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<AnalyticsEntityIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -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);
});
});

View File

@@ -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<AnalyticsSessionIdProps> {
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<AnalyticsSessionIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -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);
});
});

View File

@@ -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<PageViewIdProps> {
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<PageViewIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -1,4 +1,4 @@
import type { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig'; import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
export interface SessionDTO { export interface SessionDTO {
sessionId: string; sessionId: string;

View File

@@ -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 { StepId } from '../../domain/value-objects/StepId';
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO'; import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';

View File

@@ -2,6 +2,7 @@ import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncP
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort'; import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter'; import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
import { LoggerPort } from '../ports/LoggerPort'; import { LoggerPort } from '../ports/LoggerPort';
import type { IAsyncApplicationService } from '@gridpilot/shared/application';
type ConstructorArgs = { type ConstructorArgs = {
lifecycleEmitter: IAutomationLifecycleEmitter lifecycleEmitter: IAutomationLifecycleEmitter
@@ -13,7 +14,9 @@ type ConstructorArgs = {
defaultTimeoutMs?: number defaultTimeoutMs?: number
} }
export class OverlaySyncService implements OverlaySyncPort { export class OverlaySyncService
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
{
private lifecycleEmitter: IAutomationLifecycleEmitter private lifecycleEmitter: IAutomationLifecycleEmitter
private publisher: AutomationEventPublisherPort private publisher: AutomationEventPublisherPort
private logger: LoggerPort private logger: LoggerPort
@@ -32,6 +35,10 @@ export class OverlaySyncService implements OverlaySyncPort {
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000 this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
} }
async execute(action: OverlayAction): Promise<ActionAck> {
return this.startAction(action)
}
async startAction(action: OverlayAction): Promise<ActionAck> { async startAction(action: OverlayAction): Promise<ActionAck> {
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
const seenEvents: AutomationEvent[] = [] const seenEvents: AutomationEvent[] = []

View File

@@ -1,11 +1,13 @@
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { AutomationSession } from '../../domain/entities/AutomationSession'; 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 { AutomationEnginePort } from '../ports/AutomationEnginePort';
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort'; import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort'; import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
import type { SessionDTO } from '../dto/SessionDTO'; import type { SessionDTO } from '../dto/SessionDTO';
export class StartAutomationSessionUseCase { export class StartAutomationSessionUseCase
implements AsyncUseCase<HostedSessionConfig, SessionDTO> {
constructor( constructor(
private readonly automationEngine: AutomationEnginePort, private readonly automationEngine: AutomationEnginePort,
private readonly browserAutomation: IBrowserAutomation, private readonly browserAutomation: IBrowserAutomation,

View File

@@ -1,10 +1,11 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { IEntity } from '@gridpilot/shared/domain';
import { StepId } from '../value-objects/StepId'; import { StepId } from '../value-objects/StepId';
import { SessionState } from '../value-objects/SessionState'; import { SessionState } from '../value-objects/SessionState';
import { HostedSessionConfig } from './HostedSessionConfig'; import type { HostedSessionConfig } from '../types/HostedSessionConfig';
import { AutomationDomainError } from '../errors/AutomationDomainError'; import { AutomationDomainError } from '../errors/AutomationDomainError';
export class AutomationSession { export class AutomationSession implements IEntity<string> {
private readonly _id: string; private readonly _id: string;
private _currentStep: StepId; private _currentStep: StepId;
private _state: SessionState; private _state: SessionState;

View File

@@ -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 { export interface StepExecution {
stepId: StepId; stepId: StepId;
startedAt: Date; startedAt: Date;

View File

@@ -1,8 +1,19 @@
export class AutomationDomainError extends Error { import type { IDomainError } from '@gridpilot/shared/errors';
readonly name: string = 'AutomationDomainError';
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); super(message);
this.kind = kind;
Object.setPrototypeOf(this, new.target.prototype); Object.setPrototypeOf(this, new.target.prototype);
} }
} }

View File

@@ -1,3 +1,4 @@
import type { IDomainValidationService } from '@gridpilot/shared/domain';
import { Result } from '../../../shared/result/Result'; import { Result } from '../../../shared/result/Result';
/** /**
@@ -24,6 +25,12 @@ export interface PageStateValidationResult {
unexpectedSelectors?: string[]; unexpectedSelectors?: string[];
} }
export interface PageStateValidationInput {
actualState: (selector: string) => boolean;
validation: PageStateValidation;
realMode?: boolean;
}
/** /**
* Domain service for validating page state during wizard navigation. * 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. * 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. * It validates state based on selector presence/absence without knowing HOW to check them.
*/ */
export class PageStateValidator { export class PageStateValidator
implements
IDomainValidationService<PageStateValidationInput, PageStateValidationResult, Error>
{
validate(input: PageStateValidationInput): Result<PageStateValidationResult, Error> {
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. * Validate that the page state matches expected conditions.
* *

View File

@@ -1,11 +1,21 @@
import { StepId } from '../value-objects/StepId'; import { StepId } from '../value-objects/StepId';
import { SessionState } from '../value-objects/SessionState'; import { SessionState } from '../value-objects/SessionState';
import type { IDomainValidationService } from '@gridpilot/shared/domain';
import { Result } from '../../../shared/result/Result';
export interface ValidationResult { export interface ValidationResult {
isValid: boolean; isValid: boolean;
error?: string; error?: string;
} }
export interface StepTransitionValidationInput {
currentStep: StepId;
nextStep: StepId;
state: SessionState;
}
export interface StepTransitionValidationResult extends ValidationResult {}
const STEP_DESCRIPTIONS: Record<number, string> = { const STEP_DESCRIPTIONS: Record<number, string> = {
1: 'Navigate to Hosted Racing page', 1: 'Navigate to Hosted Racing page',
2: 'Click Create a Race', 2: 'Click Create a Race',
@@ -26,7 +36,23 @@ const STEP_DESCRIPTIONS: Record<number, string> = {
17: 'Track Conditions (STOP - Manual Submit Required)', 17: 'Track Conditions (STOP - Manual Submit Required)',
}; };
export class StepTransitionValidator { export class StepTransitionValidator
implements
IDomainValidationService<StepTransitionValidationInput, StepTransitionValidationResult, Error>
{
validate(input: StepTransitionValidationInput): Result<StepTransitionValidationResult, Error> {
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( static canTransition(
currentStep: StepId, currentStep: StepId,
nextStep: StepId, nextStep: StepId,

View File

@@ -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 { export interface HostedSessionConfig {
sessionName: string; sessionName: string;
trackId: string; trackId: string;

View File

@@ -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
);
}

View File

@@ -1,6 +1,12 @@
import { AuthenticationState } from './AuthenticationState'; 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<BrowserAuthenticationStateProps> {
private readonly cookiesValid: boolean; private readonly cookiesValid: boolean;
private readonly pageAuthenticated: boolean; private readonly pageAuthenticated: boolean;
@@ -36,4 +42,17 @@ export class BrowserAuthenticationState {
getPageAuthenticationStatus(): boolean { getPageAuthenticationStatus(): boolean {
return this.pageAuthenticated; return this.pageAuthenticated;
} }
get props(): BrowserAuthenticationStateProps {
return {
cookiesValid: this.cookiesValid,
pageAuthenticated: this.pageAuthenticated,
};
}
equals(other: IValueObject<BrowserAuthenticationStateProps>): boolean {
const a = this.props;
const b = other.props;
return a.cookiesValid === b.cookiesValid && a.pageAuthenticated === b.pageAuthenticated;
}
} }

View File

@@ -1,4 +1,10 @@
export class CheckoutPrice { import type { IValueObject } from '@gridpilot/shared/domain';
export interface CheckoutPriceProps {
amountUsd: number;
}
export class CheckoutPrice implements IValueObject<CheckoutPriceProps> {
private constructor(private readonly amountUsd: number) { private constructor(private readonly amountUsd: number) {
if (amountUsd < 0) { if (amountUsd < 0) {
throw new Error('Price cannot be negative'); throw new Error('Price cannot be negative');
@@ -54,4 +60,14 @@ export class CheckoutPrice {
isZero(): boolean { isZero(): boolean {
return this.amountUsd < 0.001; return this.amountUsd < 0.001;
} }
get props(): CheckoutPriceProps {
return {
amountUsd: this.amountUsd,
};
}
equals(other: IValueObject<CheckoutPriceProps>): boolean {
return this.props.amountUsd === other.props.amountUsd;
}
} }

View File

@@ -4,7 +4,14 @@
* Represents the lifetime of an authentication session with expiry tracking. * Represents the lifetime of an authentication session with expiry tracking.
* Handles validation of session expiry dates with a configurable buffer window. * 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<SessionLifetimeProps> {
private readonly expiry: Date | null; private readonly expiry: Date | null;
private readonly bufferMinutes: number; private readonly bufferMinutes: number;
@@ -78,8 +85,23 @@ export class SessionLifetime {
if (this.expiry === null) { if (this.expiry === null) {
return Infinity; return Infinity;
} }
const remaining = this.expiry.getTime() - Date.now(); const remaining = this.expiry.getTime() - Date.now();
return Math.max(0, remaining); return Math.max(0, remaining);
} }
get props(): SessionLifetimeProps {
return {
expiry: this.expiry,
bufferMinutes: this.bufferMinutes,
};
}
equals(other: IValueObject<SessionLifetimeProps>): 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;
}
} }

View File

@@ -1,3 +1,5 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export type SessionStateValue = export type SessionStateValue =
| 'PENDING' | 'PENDING'
| 'IN_PROGRESS' | 'IN_PROGRESS'
@@ -30,7 +32,11 @@ const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
CANCELLED: [], CANCELLED: [],
}; };
export class SessionState { export interface SessionStateProps {
value: SessionStateValue;
}
export class SessionState implements IValueObject<SessionStateProps> {
private readonly _value: SessionStateValue; private readonly _value: SessionStateValue;
private constructor(value: SessionStateValue) { private constructor(value: SessionStateValue) {
@@ -93,4 +99,12 @@ export class SessionState {
this._value === 'CANCELLED' this._value === 'CANCELLED'
); );
} }
get props(): SessionStateProps {
return { value: this._value };
}
equals(other: IValueObject<SessionStateProps>): boolean {
return this.props.value === other.props.value;
}
} }

View File

@@ -1,4 +1,10 @@
export class StepId { import type { IValueObject } from '@gridpilot/shared/domain';
export interface StepIdProps {
value: number;
}
export class StepId implements IValueObject<StepIdProps> {
private readonly _value: number; private readonly _value: number;
private constructor(value: number) { private constructor(value: number) {
@@ -37,4 +43,12 @@ export class StepId {
} }
return StepId.create(this._value + 1); return StepId.create(this._value + 1);
} }
get props(): StepIdProps {
return { value: this._value };
}
equals(other: IValueObject<StepIdProps>): boolean {
return this.props.value === other.props.value;
}
} }

View File

@@ -10,9 +10,9 @@ export * from './domain/value-objects/ScreenRegion';
export * from './domain/value-objects/SessionLifetime'; export * from './domain/value-objects/SessionLifetime';
export * from './domain/value-objects/SessionState'; export * from './domain/value-objects/SessionState';
export * from './domain/entities/HostedSessionConfig';
export * from './domain/entities/StepExecution';
export * from './domain/entities/AutomationSession'; export * from './domain/entities/AutomationSession';
export * from './domain/types/HostedSessionConfig';
export * from './domain/entities/StepExecution';
export * from './domain/services/PageStateValidator'; export * from './domain/services/PageStateValidator';
export * from './domain/services/StepTransitionValidator'; export * from './domain/services/StepTransitionValidator';

View File

@@ -1,5 +1,5 @@
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort'; 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 { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort'; import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort'; import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';

View File

@@ -1,5 +1,5 @@
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort'; 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 { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort'; import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort'; import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';

View File

@@ -1,10 +1,12 @@
/** /**
* Domain Entity: Achievement * Domain Entity: Achievement
* *
* Represents an achievement that can be earned by users. * Represents an achievement that can be earned by users.
* Achievements are categorized by role (driver, steward, admin) and type. * 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 AchievementCategory = 'driver' | 'steward' | 'admin' | 'community';
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
@@ -30,7 +32,7 @@ export interface AchievementRequirement {
operator: '>=' | '>' | '=' | '<' | '<='; operator: '>=' | '>' | '=' | '<' | '<=';
} }
export class Achievement { export class Achievement implements IEntity<string> {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;

View File

@@ -6,8 +6,8 @@
*/ */
import { UserId } from '../value-objects/UserId'; import { UserId } from '../value-objects/UserId';
import type { EmailValidationResult } from '../value-objects/EmailAddress'; import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail } from '../value-objects/EmailAddress'; import { validateEmail } from '../types/EmailAddress';
export interface SponsorAccountProps { export interface SponsorAccountProps {
id: UserId; id: UserId;

View File

@@ -1,5 +1,5 @@
import type { EmailValidationResult } from '../value-objects/EmailAddress'; import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail } from '../value-objects/EmailAddress'; import { validateEmail } from '../types/EmailAddress';
import { UserId } from '../value-objects/UserId'; import { UserId } from '../value-objects/UserId';
export interface UserProps { export interface UserProps {

View File

@@ -1,9 +1,11 @@
/** /**
* Domain Entity: UserAchievement * Domain Entity: UserAchievement
* *
* Represents an achievement earned by a specific user. * Represents an achievement earned by a specific user.
*/ */
import type { IEntity } from '@gridpilot/shared/domain';
export interface UserAchievementProps { export interface UserAchievementProps {
id: string; id: string;
userId: string; userId: string;
@@ -13,7 +15,7 @@ export interface UserAchievementProps {
progress?: number; // For partial progress tracking (0-100) progress?: number; // For partial progress tracking (0-100)
} }
export class UserAchievement { export class UserAchievement implements IEntity<string> {
readonly id: string; readonly id: string;
readonly userId: string; readonly userId: string;
readonly achievementId: string; readonly achievementId: string;

View File

@@ -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<string>([
'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;
}

View File

@@ -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';
/** export interface EmailAddressProps {
* Core email validation schema value: string;
*/
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. * Value Object: EmailAddress
* This list matches the previous website-local implementation and *
* can be extended in the future without changing the public API. * 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<string>([ export class EmailAddress implements IValueObject<EmailAddressProps> {
'tempmail.com', public readonly props: EmailAddressProps;
'throwaway.email',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
]);
export function isDisposableEmail(email: string): boolean { private constructor(value: string) {
const domain = email.split('@')[1]?.toLowerCase(); this.props = { value };
return domain ? DISPOSABLE_DOMAINS.has(domain) : false; }
}
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<EmailAddressProps>): 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';

View File

@@ -1,22 +1,32 @@
export class UserId { import type { IValueObject } from '@gridpilot/shared/domain';
private readonly value: string;
export interface UserIdProps {
value: string;
}
export class UserId implements IValueObject<UserIdProps> {
public readonly props: UserIdProps;
private constructor(value: string) { private constructor(value: string) {
if (!value || !value.trim()) { if (!value || !value.trim()) {
throw new Error('UserId cannot be empty'); throw new Error('UserId cannot be empty');
} }
this.value = value; this.props = { value };
} }
public static fromString(value: string): UserId { public static fromString(value: string): UserId {
return new UserId(value); return new UserId(value);
} }
public toString(): string { get value(): string {
return this.value; return this.props.value;
} }
public equals(other: UserId): boolean { public toString(): string {
return this.value === other.value; return this.props.value;
}
public equals(other: IValueObject<UserIdProps>): boolean {
return this.props.value === other.props.value;
} }
} }

View File

@@ -1,6 +1,8 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/** /**
* Value Object: UserRating * Value Object: UserRating
* *
* Multi-dimensional rating system for users covering: * Multi-dimensional rating system for users covering:
* - Driver skill: racing ability, lap times, consistency * - Driver skill: racing ability, lap times, consistency
* - Admin competence: league management, event organization * - Admin competence: league management, event organization
@@ -37,27 +39,47 @@ const DEFAULT_DIMENSION: RatingDimension = {
lastUpdated: new Date(), lastUpdated: new Date(),
}; };
export class UserRating { export class UserRating implements IValueObject<UserRatingProps> {
readonly userId: string; readonly props: UserRatingProps;
readonly driver: RatingDimension;
readonly admin: RatingDimension;
readonly steward: RatingDimension;
readonly trust: RatingDimension;
readonly fairness: RatingDimension;
readonly overallReputation: number;
readonly createdAt: Date;
readonly updatedAt: Date;
private constructor(props: UserRatingProps) { private constructor(props: UserRatingProps) {
this.userId = props.userId; this.props = props;
this.driver = props.driver; }
this.admin = props.admin;
this.steward = props.steward; get userId(): string {
this.trust = props.trust; return this.props.userId;
this.fairness = props.fairness; }
this.overallReputation = props.overallReputation;
this.createdAt = props.createdAt; get driver(): RatingDimension {
this.updatedAt = props.updatedAt; 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 { static create(userId: string): UserRating {
@@ -83,6 +105,10 @@ export class UserRating {
return new UserRating(props); return new UserRating(props);
} }
equals(other: IValueObject<UserRatingProps>): boolean {
return this.props.userId === other.props.userId;
}
/** /**
* Update driver rating based on race performance * Update driver rating based on race performance
*/ */
@@ -241,14 +267,14 @@ export class UserRating {
private withUpdates(updates: Partial<UserRatingProps>): UserRating { private withUpdates(updates: Partial<UserRatingProps>): UserRating {
const newRating = new UserRating({ const newRating = new UserRating({
...this, ...this.props,
...updates, ...updates,
updatedAt: new Date(), updatedAt: new Date(),
}); });
// Recalculate overall reputation // Recalculate overall reputation
return new UserRating({ return new UserRating({
...newRating, ...newRating.props,
overallReputation: newRating.calculateOverallReputation(), overallReputation: newRating.calculateOverallReputation(),
}); });
} }

View File

@@ -5,10 +5,12 @@
* and creating a generation request. * and creating a generation request.
*/ */
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { FaceValidationPort } from '../ports/FaceValidationPort'; import type { FaceValidationPort } from '../ports/FaceValidationPort';
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; 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 { export interface RequestAvatarGenerationCommand {
userId: string; userId: string;
@@ -24,7 +26,8 @@ export interface RequestAvatarGenerationResult {
errorMessage?: string; errorMessage?: string;
} }
export class RequestAvatarGenerationUseCase { export class RequestAvatarGenerationUseCase
implements AsyncUseCase<RequestAvatarGenerationCommand, RequestAvatarGenerationResult> {
constructor( constructor(
private readonly avatarRepository: IAvatarGenerationRepository, private readonly avatarRepository: IAvatarGenerationRepository,
private readonly faceValidation: FaceValidationPort, private readonly faceValidation: FaceValidationPort,
@@ -85,7 +88,7 @@ export class RequestAvatarGenerationUseCase {
// Generate avatars // Generate avatars
const generationResult = await this.avatarGeneration.generateAvatars({ const generationResult = await this.avatarGeneration.generateAvatars({
facePhotoUrl: request.facePhotoUrl, facePhotoUrl: request.facePhotoUrl.value,
prompt: request.buildPrompt(), prompt: request.buildPrompt(),
suitColor: request.suitColor, suitColor: request.suitColor,
style: request.style, style: request.style,

View File

@@ -4,6 +4,7 @@
* Allows a user to select one of the generated avatars as their profile avatar. * 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'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
export interface SelectAvatarCommand { export interface SelectAvatarCommand {
@@ -18,7 +19,8 @@ export interface SelectAvatarResult {
errorMessage?: string; errorMessage?: string;
} }
export class SelectAvatarUseCase { export class SelectAvatarUseCase
implements AsyncUseCase<SelectAvatarCommand, SelectAvatarResult> {
constructor( constructor(
private readonly avatarRepository: IAvatarGenerationRepository, private readonly avatarRepository: IAvatarGenerationRepository,
) {} ) {}

View File

@@ -1,55 +1,26 @@
/** /**
* Domain Entity: AvatarGenerationRequest * Domain Entity: AvatarGenerationRequest
* *
* Represents a request to generate a racing avatar from a face photo. * Represents a request to generate a racing avatar from a face photo.
*/ */
export type RacingSuitColor = import type { IEntity } from '@gridpilot/shared/domain';
| 'red' import type {
| 'blue' AvatarGenerationRequestProps,
| 'green' AvatarGenerationStatus,
| 'yellow' AvatarStyle,
| 'orange' RacingSuitColor,
| 'purple' } from '../types/AvatarGenerationRequest';
| 'black' import { MediaUrl } from '../value-objects/MediaUrl';
| 'white'
| 'pink' export class AvatarGenerationRequest implements IEntity<string> {
| '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 {
readonly id: string; readonly id: string;
readonly userId: string; readonly userId: string;
readonly facePhotoUrl: string; readonly facePhotoUrl: MediaUrl;
readonly suitColor: RacingSuitColor; readonly suitColor: RacingSuitColor;
readonly style: AvatarStyle; readonly style: AvatarStyle;
private _status: AvatarGenerationStatus; private _status: AvatarGenerationStatus;
private _generatedAvatarUrls: string[]; private _generatedAvatarUrls: MediaUrl[];
private _selectedAvatarIndex?: number; private _selectedAvatarIndex?: number;
private _errorMessage?: string; private _errorMessage?: string;
readonly createdAt: Date; readonly createdAt: Date;
@@ -58,11 +29,11 @@ export class AvatarGenerationRequest {
private constructor(props: AvatarGenerationRequestProps) { private constructor(props: AvatarGenerationRequestProps) {
this.id = props.id; this.id = props.id;
this.userId = props.userId; this.userId = props.userId;
this.facePhotoUrl = props.facePhotoUrl; this.facePhotoUrl = MediaUrl.create(props.facePhotoUrl);
this.suitColor = props.suitColor; this.suitColor = props.suitColor;
this.style = props.style; this.style = props.style;
this._status = props.status; this._status = props.status;
this._generatedAvatarUrls = [...props.generatedAvatarUrls]; this._generatedAvatarUrls = props.generatedAvatarUrls.map(url => MediaUrl.create(url));
this._selectedAvatarIndex = props.selectedAvatarIndex; this._selectedAvatarIndex = props.selectedAvatarIndex;
this._errorMessage = props.errorMessage; this._errorMessage = props.errorMessage;
this.createdAt = props.createdAt; this.createdAt = props.createdAt;
@@ -106,7 +77,7 @@ export class AvatarGenerationRequest {
} }
get generatedAvatarUrls(): string[] { get generatedAvatarUrls(): string[] {
return [...this._generatedAvatarUrls]; return this._generatedAvatarUrls.map(url => url.value);
} }
get selectedAvatarIndex(): number | undefined { get selectedAvatarIndex(): number | undefined {
@@ -115,7 +86,7 @@ export class AvatarGenerationRequest {
get selectedAvatarUrl(): string | undefined { get selectedAvatarUrl(): string | undefined {
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) { if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
return this._generatedAvatarUrls[this._selectedAvatarIndex]; return this._generatedAvatarUrls[this._selectedAvatarIndex].value;
} }
return undefined; return undefined;
} }
@@ -149,7 +120,7 @@ export class AvatarGenerationRequest {
throw new Error('At least one avatar URL is required'); throw new Error('At least one avatar URL is required');
} }
this._status = 'completed'; this._status = 'completed';
this._generatedAvatarUrls = [...avatarUrls]; this._generatedAvatarUrls = avatarUrls.map(url => MediaUrl.create(url));
this._updatedAt = new Date(); this._updatedAt = new Date();
} }
@@ -204,11 +175,11 @@ export class AvatarGenerationRequest {
return { return {
id: this.id, id: this.id,
userId: this.userId, userId: this.userId,
facePhotoUrl: this.facePhotoUrl, facePhotoUrl: this.facePhotoUrl.value,
suitColor: this.suitColor, suitColor: this.suitColor,
style: this.style, style: this.style,
status: this._status, status: this._status,
generatedAvatarUrls: [...this._generatedAvatarUrls], generatedAvatarUrls: this._generatedAvatarUrls.map(url => url.value),
selectedAvatarIndex: this._selectedAvatarIndex, selectedAvatarIndex: this._selectedAvatarIndex,
errorMessage: this._errorMessage, errorMessage: this._errorMessage,
createdAt: this.createdAt, createdAt: this.createdAt,

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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<MediaUrlProps> {
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<MediaUrlProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -23,10 +23,8 @@ export type {
NotificationAction, NotificationAction,
} from '../domain/entities/Notification'; } from '../domain/entities/Notification';
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference'; export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
export type { NotificationType } from '../domain/value-objects/NotificationType'; export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes';
export type { NotificationChannel } from '../domain/value-objects/NotificationChannel'; export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes';
export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType';
export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel';
// Re-export repository interfaces // Re-export repository interfaces
export type { INotificationRepository } from '../domain/repositories/INotificationRepository'; export type { INotificationRepository } from '../domain/repositories/INotificationRepository';

View File

@@ -6,7 +6,7 @@
*/ */
import type { Notification } from '../../domain/entities/Notification'; 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 { export interface NotificationDeliveryResult {
success: boolean; success: boolean;

View File

@@ -4,6 +4,7 @@
* Retrieves unread notifications for a recipient. * Retrieves unread notifications for a recipient.
*/ */
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { Notification } from '../../domain/entities/Notification'; import type { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
@@ -12,7 +13,7 @@ export interface UnreadNotificationsResult {
totalCount: number; totalCount: number;
} }
export class GetUnreadNotificationsUseCase { export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
) {} ) {}

View File

@@ -4,6 +4,7 @@
* Marks a notification as read. * Marks a notification as read.
*/ */
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
@@ -12,7 +13,7 @@ export interface MarkNotificationReadCommand {
recipientId: string; // For validation recipientId: string; // For validation
} }
export class MarkNotificationReadUseCase { export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
) {} ) {}
@@ -42,7 +43,7 @@ export class MarkNotificationReadUseCase {
* *
* Marks all notifications as read for a recipient. * Marks all notifications as read for a recipient.
*/ */
export class MarkAllNotificationsReadUseCase { export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
) {} ) {}
@@ -62,7 +63,7 @@ export interface DismissNotificationCommand {
recipientId: string; recipientId: string;
} }
export class DismissNotificationUseCase { export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
) {} ) {}

View File

@@ -4,16 +4,17 @@
* Manages user notification preferences. * Manages user notification preferences.
*/ */
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { NotificationPreference } from '../../domain/entities/NotificationPreference'; import { NotificationPreference } from '../../domain/entities/NotificationPreference';
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { NotificationType } from '../../domain/value-objects/NotificationType'; import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
/** /**
* Query: GetNotificationPreferencesQuery * Query: GetNotificationPreferencesQuery
*/ */
export class GetNotificationPreferencesQuery { export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
) {} ) {}
@@ -32,7 +33,7 @@ export interface UpdateChannelPreferenceCommand {
preference: ChannelPreference; preference: ChannelPreference;
} }
export class UpdateChannelPreferenceUseCase { export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
) {} ) {}
@@ -53,7 +54,7 @@ export interface UpdateTypePreferenceCommand {
preference: TypePreference; preference: TypePreference;
} }
export class UpdateTypePreferenceUseCase { export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
) {} ) {}
@@ -74,7 +75,7 @@ export interface UpdateQuietHoursCommand {
endHour: number | undefined; endHour: number | undefined;
} }
export class UpdateQuietHoursUseCase { export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
) {} ) {}
@@ -103,7 +104,7 @@ export interface SetDigestModeCommand {
frequencyHours?: number; frequencyHours?: number;
} }
export class SetDigestModeUseCase { export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
) {} ) {}

View File

@@ -6,13 +6,13 @@
*/ */
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { Notification } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification';
import type { NotificationData } from '../../domain/entities/Notification'; import type { NotificationData } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway'; import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
import type { NotificationType } from '../../domain/value-objects/NotificationType'; import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export interface SendNotificationCommand { export interface SendNotificationCommand {
recipientId: string; recipientId: string;
@@ -43,7 +43,7 @@ export interface SendNotificationResult {
deliveryResults: NotificationDeliveryResult[]; deliveryResults: NotificationDeliveryResult[];
} }
export class SendNotificationUseCase { export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,

View File

@@ -5,10 +5,11 @@
* Immutable entity with factory methods and domain validation. * Immutable entity with factory methods and domain validation.
*/ */
import type { IEntity } from '@gridpilot/shared/domain';
import { NotificationDomainError } from '../errors/NotificationDomainError'; import { NotificationDomainError } from '../errors/NotificationDomainError';
import { NotificationId } from '../value-objects/NotificationId';
import type { NotificationType } from '../value-objects/NotificationType'; import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required'; export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required';
@@ -54,7 +55,7 @@ export interface NotificationAction {
} }
export interface NotificationProps { export interface NotificationProps {
id: string; id: NotificationId;
/** Driver who receives this notification */ /** Driver who receives this notification */
recipientId: string; recipientId: string;
/** Type of notification */ /** Type of notification */
@@ -85,15 +86,17 @@ export interface NotificationProps {
respondedAt?: Date; respondedAt?: Date;
} }
export class Notification { export class Notification implements IEntity<string> {
private constructor(private readonly props: NotificationProps) {} private constructor(private readonly props: NotificationProps) {}
static create(props: Omit<NotificationProps, 'status' | 'createdAt' | 'urgency'> & { static create(props: Omit<NotificationProps, 'id' | 'status' | 'createdAt' | 'urgency'> & {
id: string;
status?: NotificationStatus; status?: NotificationStatus;
createdAt?: Date; createdAt?: Date;
urgency?: NotificationUrgency; urgency?: NotificationUrgency;
}): Notification { }): 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.recipientId) throw new NotificationDomainError('Recipient ID is required');
if (!props.type) throw new NotificationDomainError('Notification type is required'); if (!props.type) throw new NotificationDomainError('Notification type is required');
if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required'); if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required');
@@ -105,13 +108,14 @@ export class Notification {
return new Notification({ return new Notification({
...props, ...props,
id,
status: props.status ?? defaultStatus, status: props.status ?? defaultStatus,
urgency: props.urgency ?? 'silent', urgency: props.urgency ?? 'silent',
createdAt: props.createdAt ?? new Date(), 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 recipientId(): string { return this.props.recipientId; }
get type(): NotificationType { return this.props.type; } get type(): NotificationType { return this.props.type; }
get title(): string { return this.props.title; } get title(): string { return this.props.title; }
@@ -210,7 +214,10 @@ export class Notification {
/** /**
* Convert to plain object for serialization * Convert to plain object for serialization
*/ */
toJSON(): NotificationProps { toJSON(): Omit<NotificationProps, 'id'> & { id: string } {
return { ...this.props }; return {
...this.props,
id: this.props.id.value,
};
} }
} }

View File

@@ -4,10 +4,10 @@
* Represents a user's notification preferences for different channels and types. * Represents a user's notification preferences for different channels and types.
*/ */
import type { NotificationType } from '../value-objects/NotificationType'; import type { IEntity } from '@gridpilot/shared/domain';
import type { NotificationChannel } from '../value-objects/NotificationChannel'; import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
import { NotificationDomainError } from '../errors/NotificationDomainError'; import { NotificationDomainError } from '../errors/NotificationDomainError';
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel'; import { QuietHours } from '../value-objects/QuietHours';
export interface ChannelPreference { export interface ChannelPreference {
/** Whether this channel is enabled */ /** Whether this channel is enabled */
@@ -24,6 +24,8 @@ export interface TypePreference {
} }
export interface NotificationPreferenceProps { export interface NotificationPreferenceProps {
/** Aggregate ID for this preference (usually same as driverId) */
id: string;
/** Driver ID this preference belongs to */ /** Driver ID this preference belongs to */
driverId: string; driverId: string;
/** Global channel preferences */ /** Global channel preferences */
@@ -42,10 +44,13 @@ export interface NotificationPreferenceProps {
updatedAt: Date; updatedAt: Date;
} }
export class NotificationPreference { export class NotificationPreference implements IEntity<string> {
private constructor(private readonly props: NotificationPreferenceProps) {} private constructor(private readonly props: NotificationPreferenceProps) {}
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference { static create(
props: Omit<NotificationPreferenceProps, 'updatedAt'> & { 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.driverId) throw new NotificationDomainError('Driver ID is required');
if (!props.channels) throw new NotificationDomainError('Channel preferences are required'); if (!props.channels) throw new NotificationDomainError('Channel preferences are required');
@@ -60,6 +65,7 @@ export class NotificationPreference {
*/ */
static createDefault(driverId: string): NotificationPreference { static createDefault(driverId: string): NotificationPreference {
return new NotificationPreference({ return new NotificationPreference({
id: driverId,
driverId, driverId,
channels: { channels: {
in_app: { enabled: true }, 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 driverId(): string { return this.props.driverId; }
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; } get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined { get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
@@ -83,6 +90,13 @@ export class NotificationPreference {
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; } get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
get updatedAt(): Date { return this.props.updatedAt; } 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 * Check if a specific channel is enabled
*/ */
@@ -117,20 +131,13 @@ export class NotificationPreference {
* Check if current time is in quiet hours * Check if current time is in quiet hours
*/ */
isInQuietHours(): boolean { isInQuietHours(): boolean {
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) { const quietHours = this.quietHours;
if (!quietHours) {
return false; return false;
} }
const now = new Date(); const now = new Date();
const currentHour = now.getHours(); return quietHours.containsHour(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;
}
} }
/** /**
@@ -165,10 +172,12 @@ export class NotificationPreference {
* Update quiet hours * Update quiet hours
*/ */
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference { updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
const validated = start === undefined || end === undefined ? undefined : QuietHours.create(start, end);
return new NotificationPreference({ return new NotificationPreference({
...this.props, ...this.props,
quietHoursStart: start, quietHoursStart: validated?.props.startHour,
quietHoursEnd: end, quietHoursEnd: validated?.props.endHour,
updatedAt: new Date(), updatedAt: new Date(),
}); });
} }

View File

@@ -1,8 +1,19 @@
export class NotificationDomainError extends Error { import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
readonly name: string = 'NotificationDomainError';
constructor(message: string) { /**
* Domain Error: NotificationDomainError
*
* Implements the shared IDomainError contract for notification domain failures.
*/
export class NotificationDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
readonly name = 'NotificationDomainError';
readonly type = 'domain' as const;
readonly context = 'notifications';
readonly kind: CommonDomainErrorKind;
constructor(message: string, kind: CommonDomainErrorKind = 'validation') {
super(message); super(message);
this.kind = kind;
Object.setPrototypeOf(this, new.target.prototype); Object.setPrototypeOf(this, new.target.prototype);
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
import type { Notification } from '../entities/Notification'; import type { Notification } from '../entities/Notification';
import type { NotificationType } from '../value-objects/NotificationType'; import type { NotificationType } from '../types/NotificationTypes';
export interface INotificationRepository { export interface INotificationRepository {
/** /**

View File

@@ -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<NotificationChannel, string> = {
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. * Defines the types of notifications that can be sent in the system.
*/ */
export type NotificationType = export type NotificationType =
// Protest-related // 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_requested' // Steward requests your defense
| 'protest_defense_submitted' // Accused submitted their defense | 'protest_defense_submitted' // Accused submitted their defense
| 'protest_comment_added' // New comment on a protest you're involved in | 'protest_comment_added' // New comment on a protest you're involved in
| 'protest_vote_required' // You need to vote on a protest | 'protest_vote_required' // You need to vote on a protest
| 'protest_vote_cast' // Someone voted on a protest | 'protest_vote_cast' // Someone voted on a protest
| 'protest_resolved' // Protest has been resolved | 'protest_resolved' // Protest has been resolved
// Penalty-related // Penalty-related
| 'penalty_issued' // A penalty was issued to you | 'penalty_issued' // A penalty was issued to you
| 'penalty_appealed' // Penalty appeal submitted | 'penalty_appealed' // Penalty appeal submitted
| 'penalty_appeal_resolved' // Appeal was resolved | 'penalty_appeal_resolved' // Appeal was resolved
// Race-related // Race-related
| 'race_registration_open' // Race registration is now open | 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder | 'race_reminder' // Race starting soon reminder
| 'race_results_posted' // Race results are available | 'race_results_posted' // Race results are available
// League-related // League-related
| 'league_invite' // You were invited to a league | 'league_invite' // You were invited to a league
| 'league_join_request' // Someone requested to join your league | 'league_join_request' // Someone requested to join your league
| 'league_join_approved' // Your join request was approved | 'league_join_approved' // Your join request was approved
| 'league_join_rejected' // Your join request was rejected | 'league_join_rejected' // Your join request was rejected
| 'league_role_changed' // Your role in a league changed | 'league_role_changed' // Your role in a league changed
// Team-related // Team-related
| 'team_invite' // You were invited to a team | 'team_invite' // You were invited to a team
| 'team_join_request' // Someone requested to join your team | 'team_join_request' // Someone requested to join your team
| 'team_join_approved' // Your team join request was approved | 'team_join_approved' // Your team join request was approved
// Sponsorship-related // Sponsorship-related
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity | 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
| 'sponsorship_request_accepted' // Your sponsorship request was accepted | 'sponsorship_request_accepted' // Your sponsorship request was accepted
| 'sponsorship_request_rejected' // Your sponsorship request was rejected | 'sponsorship_request_rejected' // Your sponsorship request was rejected
| 'sponsorship_request_withdrawn' // A sponsor withdrew their request | 'sponsorship_request_withdrawn' // A sponsor withdrew their request
| 'sponsorship_activated' // Sponsorship is now active | 'sponsorship_activated' // Sponsorship is now active
| 'sponsorship_payment_received' // Payment received for sponsorship | 'sponsorship_payment_received' // Payment received for sponsorship
// System // System
| 'system_announcement'; // System-wide announcement | 'system_announcement'; // System-wide announcement
/** /**
* Get human-readable title for notification type * Get human-readable title for notification type

View File

@@ -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<NotificationChannel, string> = {
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'];

View File

@@ -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);
});
});

View File

@@ -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<NotificationIdProps> {
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<NotificationIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -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);
});
});

View File

@@ -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<QuietHoursProps> {
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<QuietHoursProps>): boolean {
return (
this.props.startHour === other.props.startHour &&
this.props.endHour === other.props.endHour
);
}
}

View File

@@ -10,7 +10,7 @@ import type {
INotificationGateway, INotificationGateway,
NotificationDeliveryResult NotificationDeliveryResult
} from '../../application/ports/INotificationGateway'; } from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; import type { NotificationChannel } from '../../domain/types/NotificationTypes';
export interface DiscordAdapterConfig { export interface DiscordAdapterConfig {
webhookUrl?: string; webhookUrl?: string;

View File

@@ -10,7 +10,7 @@ import type {
INotificationGateway, INotificationGateway,
NotificationDeliveryResult NotificationDeliveryResult
} from '../../application/ports/INotificationGateway'; } from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; import type { NotificationChannel } from '../../domain/types/NotificationTypes';
export interface EmailAdapterConfig { export interface EmailAdapterConfig {
smtpHost?: string; smtpHost?: string;

View File

@@ -10,7 +10,7 @@ import type {
INotificationGateway, INotificationGateway,
NotificationDeliveryResult NotificationDeliveryResult
} from '../../application/ports/INotificationGateway'; } from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; import type { NotificationChannel } from '../../domain/types/NotificationTypes';
export class InAppNotificationAdapter implements INotificationGateway { export class InAppNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'in_app'; private readonly channel: NotificationChannel = 'in_app';

View File

@@ -5,7 +5,7 @@
*/ */
import type { Notification } from '../../domain/entities/Notification'; import type { Notification } from '../../domain/entities/Notification';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel'; import type { NotificationChannel } from '../../domain/types/NotificationTypes';
import type { import type {
INotificationGateway, INotificationGateway,
INotificationGatewayRegistry, INotificationGatewayRegistry,

View File

@@ -6,7 +6,7 @@
import { Notification } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; 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 { export class InMemoryNotificationRepository implements INotificationRepository {
private notifications: Map<string, Notification> = new Map(); private notifications: Map<string, Notification> = new Map();

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO'; import type { 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 { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone'; import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern'; import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy'; import type { RecurrenceStrategy } from '../../domain/types/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy'; import { RecurrenceStrategyFactory } from '../../domain/types/RecurrenceStrategy';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule'; import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError'; import { BusinessRuleViolationError } from '../errors/RacingApplicationError';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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