wip
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
|
||||
import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
||||
|
||||
export interface AuthStatusEvent {
|
||||
|
||||
@@ -198,7 +198,7 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
sessionCount: 2,
|
||||
roundsPlanned: 8,
|
||||
// Default to Saturday races, weekly, starting next week
|
||||
weekdays: ['Sat'] as import('@gridpilot/racing/domain/value-objects/Weekday').Weekday[],
|
||||
weekdays: ['Sat'] as import('@gridpilot/racing/domain/types/Weekday').Weekday[],
|
||||
recurrenceStrategy: 'weekly' as const,
|
||||
raceStartTime: '20:00',
|
||||
timezoneId: 'UTC',
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueSchedulePreviewDTO,
|
||||
} from '@gridpilot/racing/application';
|
||||
import type { Weekday } from '@gridpilot/racing/domain/value-objects/Weekday';
|
||||
import type { Weekday } from '@gridpilot/racing/domain/types/Weekday';
|
||||
import Input from '@/components/ui/Input';
|
||||
import RangeField from '@/components/ui/RangeField';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy';
|
||||
import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy';
|
||||
import type {
|
||||
ILeagueFullConfigPresenter,
|
||||
LeagueFullConfigData,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
|
||||
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule';
|
||||
import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig';
|
||||
import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule';
|
||||
import type {
|
||||
ILeagueScoringConfigPresenter,
|
||||
LeagueScoringConfigData,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Query: GetEntityAnalyticsQuery
|
||||
*
|
||||
*
|
||||
* Retrieves analytics data for an entity (league, driver, team, race).
|
||||
* Returns metrics formatted for display to sponsors and admins.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import type { EntityType } from '../../domain/entities/PageView';
|
||||
import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot';
|
||||
import type { EntityType } from '../../domain/types/PageView';
|
||||
import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
|
||||
|
||||
export interface GetEntityAnalyticsInput {
|
||||
entityType: EntityType;
|
||||
@@ -41,7 +42,8 @@ export interface EntityAnalyticsOutput {
|
||||
};
|
||||
}
|
||||
|
||||
export class GetEntityAnalyticsQuery {
|
||||
export class GetEntityAnalyticsQuery
|
||||
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
|
||||
@@ -22,7 +23,8 @@ export interface RecordEngagementOutput {
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase {
|
||||
export class RecordEngagementUseCase
|
||||
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
|
||||
constructor(private readonly engagementRepository: IEngagementRepository) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Use Case: RecordPageViewUseCase
|
||||
*
|
||||
*
|
||||
* Records a page view event when a visitor accesses an entity page.
|
||||
*/
|
||||
|
||||
import { PageView, type EntityType, type VisitorType } from '../../domain/entities/PageView';
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { PageView } from '../../domain/entities/PageView';
|
||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
@@ -22,7 +24,8 @@ export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewUseCase {
|
||||
export class RecordPageViewUseCase
|
||||
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
|
||||
constructor(private readonly pageViewRepository: IPageViewRepository) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
|
||||
@@ -1,52 +1,34 @@
|
||||
/**
|
||||
* Domain Entity: AnalyticsSnapshot
|
||||
*
|
||||
*
|
||||
* Aggregated analytics data for a specific entity over a time period.
|
||||
* Pre-calculated metrics for sponsor dashboard and entity analytics.
|
||||
*/
|
||||
|
||||
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type {
|
||||
AnalyticsSnapshotProps,
|
||||
AnalyticsMetrics,
|
||||
SnapshotEntityType,
|
||||
SnapshotPeriod,
|
||||
} from '../types/AnalyticsSnapshot';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
|
||||
export interface AnalyticsMetrics {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgSessionDuration: number;
|
||||
bounceRate: number;
|
||||
engagementScore: number;
|
||||
sponsorClicks: number;
|
||||
sponsorUrlClicks: number;
|
||||
socialShares: number;
|
||||
leagueJoins: number;
|
||||
raceRegistrations: number;
|
||||
exposureValue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsSnapshotProps {
|
||||
id: string;
|
||||
entityType: SnapshotEntityType;
|
||||
entityId: string;
|
||||
period: SnapshotPeriod;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
metrics: AnalyticsMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class AnalyticsSnapshot {
|
||||
export class AnalyticsSnapshot implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly entityType: SnapshotEntityType;
|
||||
readonly entityId: string;
|
||||
readonly period: SnapshotPeriod;
|
||||
readonly startDate: Date;
|
||||
readonly endDate: Date;
|
||||
readonly metrics: AnalyticsMetrics;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
|
||||
private constructor(props: AnalyticsSnapshotProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.period = props.period;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
@@ -54,6 +36,10 @@ export class AnalyticsSnapshot {
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
|
||||
this.validate(props);
|
||||
|
||||
|
||||
@@ -1,51 +1,35 @@
|
||||
/**
|
||||
* Domain Entity: EngagementEvent
|
||||
*
|
||||
*
|
||||
* Represents user interactions beyond page views.
|
||||
* Tracks clicks, downloads, sign-ups, and other engagement actions.
|
||||
*/
|
||||
|
||||
export type EngagementAction =
|
||||
| 'click_sponsor_logo'
|
||||
| 'click_sponsor_url'
|
||||
| 'download_livery_pack'
|
||||
| 'join_league'
|
||||
| 'register_race'
|
||||
| 'view_standings'
|
||||
| 'view_schedule'
|
||||
| 'share_social'
|
||||
| 'contact_sponsor';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type {
|
||||
EngagementAction,
|
||||
EngagementEntityType,
|
||||
EngagementEventProps,
|
||||
} from '../types/EngagementEvent';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
|
||||
export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship';
|
||||
|
||||
export interface EngagementEventProps {
|
||||
id: string;
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class EngagementEvent {
|
||||
export class EngagementEvent implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly action: EngagementAction;
|
||||
readonly entityType: EngagementEntityType;
|
||||
readonly entityId: string;
|
||||
readonly actorId?: string;
|
||||
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
readonly sessionId: string;
|
||||
readonly metadata?: Record<string, string | number | boolean>;
|
||||
readonly timestamp: Date;
|
||||
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
|
||||
private constructor(props: EngagementEventProps) {
|
||||
this.id = props.id;
|
||||
this.action = props.action;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.actorId = props.actorId;
|
||||
this.actorType = props.actorType;
|
||||
this.sessionId = props.sessionId;
|
||||
@@ -53,6 +37,10 @@ export class EngagementEvent {
|
||||
this.timestamp = props.timestamp;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
|
||||
this.validate(props);
|
||||
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
/**
|
||||
* Domain Entity: PageView
|
||||
*
|
||||
*
|
||||
* Represents a single page view event for analytics tracking.
|
||||
* Captures visitor interactions with leagues, drivers, teams, races.
|
||||
*/
|
||||
|
||||
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
export type VisitorType = 'anonymous' | 'driver' | 'sponsor';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
|
||||
import { PageViewId } from '../value-objects/PageViewId';
|
||||
|
||||
export interface PageViewProps {
|
||||
id: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
timestamp: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class PageView {
|
||||
readonly id: string;
|
||||
export class PageView implements IEntity<string> {
|
||||
readonly entityType: EntityType;
|
||||
readonly entityId: string;
|
||||
readonly visitorId?: string;
|
||||
readonly visitorType: VisitorType;
|
||||
readonly sessionId: string;
|
||||
readonly referrer?: string;
|
||||
readonly userAgent?: string;
|
||||
readonly country?: string;
|
||||
readonly timestamp: Date;
|
||||
readonly durationMs?: number;
|
||||
|
||||
private readonly idVo: PageViewId;
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
private readonly sessionIdVo: AnalyticsSessionId;
|
||||
|
||||
private constructor(props: PageViewProps) {
|
||||
this.id = props.id;
|
||||
this.idVo = PageViewId.create(props.id);
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.visitorId = props.visitorId;
|
||||
this.visitorType = props.visitorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
|
||||
this.referrer = props.referrer;
|
||||
this.userAgent = props.userAgent;
|
||||
this.country = props.country;
|
||||
@@ -49,6 +39,18 @@ export class PageView {
|
||||
this.durationMs = props.durationMs;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.idVo.value;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.sessionIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
|
||||
35
packages/analytics/domain/types/AnalyticsSnapshot.ts
Normal file
35
packages/analytics/domain/types/AnalyticsSnapshot.ts
Normal 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;
|
||||
}
|
||||
37
packages/analytics/domain/types/EngagementEvent.ts
Normal file
37
packages/analytics/domain/types/EngagementEvent.ts
Normal 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;
|
||||
}
|
||||
24
packages/analytics/domain/types/PageView.ts
Normal file
24
packages/analytics/domain/types/PageView.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
37
packages/analytics/domain/value-objects/AnalyticsEntityId.ts
Normal file
37
packages/analytics/domain/value-objects/AnalyticsEntityId.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
29
packages/analytics/domain/value-objects/PageViewId.test.ts
Normal file
29
packages/analytics/domain/value-objects/PageViewId.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/analytics/domain/value-objects/PageViewId.ts
Normal file
36
packages/analytics/domain/value-objects/PageViewId.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
|
||||
export interface SessionDTO {
|
||||
sessionId: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncP
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||
import { LoggerPort } from '../ports/LoggerPort';
|
||||
import type { IAsyncApplicationService } from '@gridpilot/shared/application';
|
||||
|
||||
type ConstructorArgs = {
|
||||
lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
@@ -13,7 +14,9 @@ type ConstructorArgs = {
|
||||
defaultTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class OverlaySyncService implements OverlaySyncPort {
|
||||
export class OverlaySyncService
|
||||
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
||||
{
|
||||
private lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
private publisher: AutomationEventPublisherPort
|
||||
private logger: LoggerPort
|
||||
@@ -32,6 +35,10 @@ export class OverlaySyncService implements OverlaySyncPort {
|
||||
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
|
||||
}
|
||||
|
||||
async execute(action: OverlayAction): Promise<ActionAck> {
|
||||
return this.startAction(action)
|
||||
}
|
||||
|
||||
async startAction(action: OverlayAction): Promise<ActionAck> {
|
||||
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
|
||||
const seenEvents: AutomationEvent[] = []
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { AutomationEnginePort } from '../ports/AutomationEnginePort';
|
||||
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
|
||||
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
|
||||
import type { SessionDTO } from '../dto/SessionDTO';
|
||||
|
||||
export class StartAutomationSessionUseCase {
|
||||
export class StartAutomationSessionUseCase
|
||||
implements AsyncUseCase<HostedSessionConfig, SessionDTO> {
|
||||
constructor(
|
||||
private readonly automationEngine: AutomationEnginePort,
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import { HostedSessionConfig } from './HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../types/HostedSessionConfig';
|
||||
import { AutomationDomainError } from '../errors/AutomationDomainError';
|
||||
|
||||
export class AutomationSession {
|
||||
export class AutomationSession implements IEntity<string> {
|
||||
private readonly _id: string;
|
||||
private _currentStep: StepId;
|
||||
private _state: SessionState;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import type { StepId } from '../value-objects/StepId';
|
||||
|
||||
/**
|
||||
* Domain Type: StepExecution
|
||||
*
|
||||
* Represents execution metadata for a single automation step.
|
||||
* This is a pure data shape (DTO-like), not an entity or value object.
|
||||
*/
|
||||
export interface StepExecution {
|
||||
stepId: StepId;
|
||||
startedAt: Date;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export class AutomationDomainError extends Error {
|
||||
readonly name: string = 'AutomationDomainError';
|
||||
import type { IDomainError } from '@gridpilot/shared/errors';
|
||||
|
||||
constructor(message: string) {
|
||||
/**
|
||||
* Domain Error: AutomationDomainError
|
||||
*
|
||||
* Implements the shared IDomainError contract for automation domain failures.
|
||||
*/
|
||||
export class AutomationDomainError extends Error implements IDomainError {
|
||||
readonly name = 'AutomationDomainError';
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'automation';
|
||||
readonly kind: string;
|
||||
|
||||
constructor(message: string, kind: string = 'validation') {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IDomainValidationService } from '@gridpilot/shared/domain';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,12 @@ export interface PageStateValidationResult {
|
||||
unexpectedSelectors?: string[];
|
||||
}
|
||||
|
||||
export interface PageStateValidationInput {
|
||||
actualState: (selector: string) => boolean;
|
||||
validation: PageStateValidation;
|
||||
realMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain service for validating page state during wizard navigation.
|
||||
*
|
||||
@@ -32,7 +39,18 @@ export interface PageStateValidationResult {
|
||||
* Clean Architecture: This is pure domain logic with no infrastructure dependencies.
|
||||
* It validates state based on selector presence/absence without knowing HOW to check them.
|
||||
*/
|
||||
export class PageStateValidator {
|
||||
export class PageStateValidator
|
||||
implements
|
||||
IDomainValidationService<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.
|
||||
*
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import type { IDomainValidationService } from '@gridpilot/shared/domain';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StepTransitionValidationInput {
|
||||
currentStep: StepId;
|
||||
nextStep: StepId;
|
||||
state: SessionState;
|
||||
}
|
||||
|
||||
export interface StepTransitionValidationResult extends ValidationResult {}
|
||||
|
||||
const STEP_DESCRIPTIONS: Record<number, string> = {
|
||||
1: 'Navigate to Hosted Racing page',
|
||||
2: 'Click Create a Race',
|
||||
@@ -26,7 +36,23 @@ const STEP_DESCRIPTIONS: Record<number, string> = {
|
||||
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(
|
||||
currentStep: StepId,
|
||||
nextStep: StepId,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Domain Type: HostedSessionConfig
|
||||
*
|
||||
* Pure configuration shape for an iRacing hosted session.
|
||||
* This is a DTO-like domain type, not a value object or entity.
|
||||
*/
|
||||
export interface HostedSessionConfig {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
88
packages/automation/domain/types/ScreenRegion.ts
Normal file
88
packages/automation/domain/types/ScreenRegion.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { AuthenticationState } from './AuthenticationState';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export class BrowserAuthenticationState {
|
||||
export interface BrowserAuthenticationStateProps {
|
||||
cookiesValid: boolean;
|
||||
pageAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export class BrowserAuthenticationState implements IValueObject<BrowserAuthenticationStateProps> {
|
||||
private readonly cookiesValid: boolean;
|
||||
private readonly pageAuthenticated: boolean;
|
||||
|
||||
@@ -36,4 +42,17 @@ export class BrowserAuthenticationState {
|
||||
getPageAuthenticationStatus(): boolean {
|
||||
return this.pageAuthenticated;
|
||||
}
|
||||
|
||||
get props(): BrowserAuthenticationStateProps {
|
||||
return {
|
||||
cookiesValid: this.cookiesValid,
|
||||
pageAuthenticated: this.pageAuthenticated,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<BrowserAuthenticationStateProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.cookiesValid === b.cookiesValid && a.pageAuthenticated === b.pageAuthenticated;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
if (amountUsd < 0) {
|
||||
throw new Error('Price cannot be negative');
|
||||
@@ -54,4 +60,14 @@ export class CheckoutPrice {
|
||||
isZero(): boolean {
|
||||
return this.amountUsd < 0.001;
|
||||
}
|
||||
|
||||
get props(): CheckoutPriceProps {
|
||||
return {
|
||||
amountUsd: this.amountUsd,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<CheckoutPriceProps>): boolean {
|
||||
return this.props.amountUsd === other.props.amountUsd;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,14 @@
|
||||
* Represents the lifetime of an authentication session with expiry tracking.
|
||||
* Handles validation of session expiry dates with a configurable buffer window.
|
||||
*/
|
||||
export class SessionLifetime {
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SessionLifetimeProps {
|
||||
expiry: Date | null;
|
||||
bufferMinutes: number;
|
||||
}
|
||||
|
||||
export class SessionLifetime implements IValueObject<SessionLifetimeProps> {
|
||||
private readonly expiry: Date | null;
|
||||
private readonly bufferMinutes: number;
|
||||
|
||||
@@ -78,8 +85,23 @@ export class SessionLifetime {
|
||||
if (this.expiry === null) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
|
||||
const remaining = this.expiry.getTime() - Date.now();
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
get props(): SessionLifetimeProps {
|
||||
return {
|
||||
expiry: this.expiry,
|
||||
bufferMinutes: this.bufferMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SessionStateValue =
|
||||
| 'PENDING'
|
||||
| 'IN_PROGRESS'
|
||||
@@ -30,7 +32,11 @@ const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
export class SessionState {
|
||||
export interface SessionStateProps {
|
||||
value: SessionStateValue;
|
||||
}
|
||||
|
||||
export class SessionState implements IValueObject<SessionStateProps> {
|
||||
private readonly _value: SessionStateValue;
|
||||
|
||||
private constructor(value: SessionStateValue) {
|
||||
@@ -93,4 +99,12 @@ export class SessionState {
|
||||
this._value === 'CANCELLED'
|
||||
);
|
||||
}
|
||||
|
||||
get props(): SessionStateProps {
|
||||
return { value: this._value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionStateProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
@@ -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 constructor(value: number) {
|
||||
@@ -37,4 +43,12 @@ export class StepId {
|
||||
}
|
||||
return StepId.create(this._value + 1);
|
||||
}
|
||||
|
||||
get props(): StepIdProps {
|
||||
return { value: this._value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<StepIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ export * from './domain/value-objects/ScreenRegion';
|
||||
export * from './domain/value-objects/SessionLifetime';
|
||||
export * from './domain/value-objects/SessionState';
|
||||
|
||||
export * from './domain/entities/HostedSessionConfig';
|
||||
export * from './domain/entities/StepExecution';
|
||||
export * from './domain/entities/AutomationSession';
|
||||
export * from './domain/types/HostedSessionConfig';
|
||||
export * from './domain/entities/StepExecution';
|
||||
|
||||
export * from './domain/services/PageStateValidator';
|
||||
export * from './domain/services/StepTransitionValidator';
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: Achievement
|
||||
*
|
||||
*
|
||||
* Represents an achievement that can be earned by users.
|
||||
* Achievements are categorized by role (driver, steward, admin) and type.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type AchievementCategory = 'driver' | 'steward' | 'admin' | 'community';
|
||||
|
||||
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
@@ -30,7 +32,7 @@ export interface AchievementRequirement {
|
||||
operator: '>=' | '>' | '=' | '<' | '<=';
|
||||
}
|
||||
|
||||
export class Achievement {
|
||||
export class Achievement implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import type { EmailValidationResult } from '../value-objects/EmailAddress';
|
||||
import { validateEmail } from '../value-objects/EmailAddress';
|
||||
import type { EmailValidationResult } from '../types/EmailAddress';
|
||||
import { validateEmail } from '../types/EmailAddress';
|
||||
|
||||
export interface SponsorAccountProps {
|
||||
id: UserId;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EmailValidationResult } from '../value-objects/EmailAddress';
|
||||
import { validateEmail } from '../value-objects/EmailAddress';
|
||||
import type { EmailValidationResult } from '../types/EmailAddress';
|
||||
import { validateEmail } from '../types/EmailAddress';
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
|
||||
export interface UserProps {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Domain Entity: UserAchievement
|
||||
*
|
||||
*
|
||||
* Represents an achievement earned by a specific user.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface UserAchievementProps {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -13,7 +15,7 @@ export interface UserAchievementProps {
|
||||
progress?: number; // For partial progress tracking (0-100)
|
||||
}
|
||||
|
||||
export class UserAchievement {
|
||||
export class UserAchievement implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly achievementId: string;
|
||||
|
||||
65
packages/identity/domain/types/EmailAddress.ts
Normal file
65
packages/identity/domain/types/EmailAddress.ts
Normal 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;
|
||||
}
|
||||
@@ -1,64 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import type { EmailValidationResult } from '../types/EmailAddress';
|
||||
import { validateEmail, isDisposableEmail } from '../types/EmailAddress';
|
||||
|
||||
/**
|
||||
* Core email validation schema
|
||||
*/
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(6, 'Email too short')
|
||||
.max(254, 'Email too long')
|
||||
.email('Invalid email format');
|
||||
|
||||
export type EmailValidationSuccess = {
|
||||
success: true;
|
||||
email: string;
|
||||
error?: undefined;
|
||||
};
|
||||
|
||||
export type EmailValidationFailure = {
|
||||
success: false;
|
||||
email?: undefined;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type EmailValidationResult = EmailValidationSuccess | EmailValidationFailure;
|
||||
|
||||
/**
|
||||
* Validate and normalize an email address.
|
||||
* Mirrors the previous apps/website/lib/email-validation.ts behavior.
|
||||
*/
|
||||
export function validateEmail(email: string): EmailValidationResult {
|
||||
const result = emailSchema.safeParse(email);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
email: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.errors[0]?.message || 'Invalid email',
|
||||
};
|
||||
export interface EmailAddressProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic disposable email detection.
|
||||
* This list matches the previous website-local implementation and
|
||||
* can be extended in the future without changing the public API.
|
||||
* Value Object: EmailAddress
|
||||
*
|
||||
* Wraps a validated, normalized email string and provides equality semantics.
|
||||
* Validation and helper utilities live in domain/types/EmailAddress.
|
||||
*/
|
||||
export const DISPOSABLE_DOMAINS = new Set<string>([
|
||||
'tempmail.com',
|
||||
'throwaway.email',
|
||||
'guerrillamail.com',
|
||||
'mailinator.com',
|
||||
'10minutemail.com',
|
||||
]);
|
||||
export class EmailAddress implements IValueObject<EmailAddressProps> {
|
||||
public readonly props: EmailAddressProps;
|
||||
|
||||
export function isDisposableEmail(email: string): boolean {
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
|
||||
}
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
static create(raw: string): EmailAddress {
|
||||
const result: EmailValidationResult = validateEmail(raw);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return new EmailAddress(result.email);
|
||||
}
|
||||
|
||||
static fromValidated(value: string): EmailAddress {
|
||||
return new EmailAddress(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<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';
|
||||
@@ -1,22 +1,32 @@
|
||||
export class UserId {
|
||||
private readonly value: string;
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface UserIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class UserId implements IValueObject<UserIdProps> {
|
||||
public readonly props: UserIdProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error('UserId cannot be empty');
|
||||
}
|
||||
this.value = value;
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
public static fromString(value: string): UserId {
|
||||
return new UserId(value);
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.value;
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
public equals(other: UserId): boolean {
|
||||
return this.value === other.value;
|
||||
public toString(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
public equals(other: IValueObject<UserIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: UserRating
|
||||
*
|
||||
*
|
||||
* Multi-dimensional rating system for users covering:
|
||||
* - Driver skill: racing ability, lap times, consistency
|
||||
* - Admin competence: league management, event organization
|
||||
@@ -37,27 +39,47 @@ const DEFAULT_DIMENSION: RatingDimension = {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
export class UserRating {
|
||||
readonly userId: string;
|
||||
readonly driver: RatingDimension;
|
||||
readonly admin: RatingDimension;
|
||||
readonly steward: RatingDimension;
|
||||
readonly trust: RatingDimension;
|
||||
readonly fairness: RatingDimension;
|
||||
readonly overallReputation: number;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
export class UserRating implements IValueObject<UserRatingProps> {
|
||||
readonly props: UserRatingProps;
|
||||
|
||||
private constructor(props: UserRatingProps) {
|
||||
this.userId = props.userId;
|
||||
this.driver = props.driver;
|
||||
this.admin = props.admin;
|
||||
this.steward = props.steward;
|
||||
this.trust = props.trust;
|
||||
this.fairness = props.fairness;
|
||||
this.overallReputation = props.overallReputation;
|
||||
this.createdAt = props.createdAt;
|
||||
this.updatedAt = props.updatedAt;
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
get userId(): string {
|
||||
return this.props.userId;
|
||||
}
|
||||
|
||||
get driver(): RatingDimension {
|
||||
return this.props.driver;
|
||||
}
|
||||
|
||||
get admin(): RatingDimension {
|
||||
return this.props.admin;
|
||||
}
|
||||
|
||||
get steward(): RatingDimension {
|
||||
return this.props.steward;
|
||||
}
|
||||
|
||||
get trust(): RatingDimension {
|
||||
return this.props.trust;
|
||||
}
|
||||
|
||||
get fairness(): RatingDimension {
|
||||
return this.props.fairness;
|
||||
}
|
||||
|
||||
get overallReputation(): number {
|
||||
return this.props.overallReputation;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
static create(userId: string): UserRating {
|
||||
@@ -83,6 +105,10 @@ export class UserRating {
|
||||
return new UserRating(props);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<UserRatingProps>): boolean {
|
||||
return this.props.userId === other.props.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update driver rating based on race performance
|
||||
*/
|
||||
@@ -241,14 +267,14 @@ export class UserRating {
|
||||
|
||||
private withUpdates(updates: Partial<UserRatingProps>): UserRating {
|
||||
const newRating = new UserRating({
|
||||
...this,
|
||||
...this.props,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
|
||||
// Recalculate overall reputation
|
||||
return new UserRating({
|
||||
...newRating,
|
||||
...newRating.props,
|
||||
overallReputation: newRating.calculateOverallReputation(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* and creating a generation request.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
import type { FaceValidationPort } from '../ports/FaceValidationPort';
|
||||
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
|
||||
import { AvatarGenerationRequest, type RacingSuitColor, type AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
|
||||
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
|
||||
import type { RacingSuitColor, AvatarStyle } from '../../domain/types/AvatarGenerationRequest';
|
||||
|
||||
export interface RequestAvatarGenerationCommand {
|
||||
userId: string;
|
||||
@@ -24,7 +26,8 @@ export interface RequestAvatarGenerationResult {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class RequestAvatarGenerationUseCase {
|
||||
export class RequestAvatarGenerationUseCase
|
||||
implements AsyncUseCase<RequestAvatarGenerationCommand, RequestAvatarGenerationResult> {
|
||||
constructor(
|
||||
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||
private readonly faceValidation: FaceValidationPort,
|
||||
@@ -85,7 +88,7 @@ export class RequestAvatarGenerationUseCase {
|
||||
|
||||
// Generate avatars
|
||||
const generationResult = await this.avatarGeneration.generateAvatars({
|
||||
facePhotoUrl: request.facePhotoUrl,
|
||||
facePhotoUrl: request.facePhotoUrl.value,
|
||||
prompt: request.buildPrompt(),
|
||||
suitColor: request.suitColor,
|
||||
style: request.style,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Allows a user to select one of the generated avatars as their profile avatar.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
|
||||
export interface SelectAvatarCommand {
|
||||
@@ -18,7 +19,8 @@ export interface SelectAvatarResult {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class SelectAvatarUseCase {
|
||||
export class SelectAvatarUseCase
|
||||
implements AsyncUseCase<SelectAvatarCommand, SelectAvatarResult> {
|
||||
constructor(
|
||||
private readonly avatarRepository: IAvatarGenerationRepository,
|
||||
) {}
|
||||
|
||||
@@ -1,55 +1,26 @@
|
||||
/**
|
||||
* Domain Entity: AvatarGenerationRequest
|
||||
*
|
||||
*
|
||||
* Represents a request to generate a racing avatar from a face photo.
|
||||
*/
|
||||
|
||||
export type RacingSuitColor =
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'orange'
|
||||
| 'purple'
|
||||
| 'black'
|
||||
| 'white'
|
||||
| 'pink'
|
||||
| 'cyan';
|
||||
|
||||
export type AvatarStyle =
|
||||
| 'realistic'
|
||||
| 'cartoon'
|
||||
| 'pixel-art';
|
||||
|
||||
export type AvatarGenerationStatus =
|
||||
| 'pending'
|
||||
| 'validating'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface AvatarGenerationRequestProps {
|
||||
id: string;
|
||||
userId: string;
|
||||
facePhotoUrl: string;
|
||||
suitColor: RacingSuitColor;
|
||||
style: AvatarStyle;
|
||||
status: AvatarGenerationStatus;
|
||||
generatedAvatarUrls: string[];
|
||||
selectedAvatarIndex?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class AvatarGenerationRequest {
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type {
|
||||
AvatarGenerationRequestProps,
|
||||
AvatarGenerationStatus,
|
||||
AvatarStyle,
|
||||
RacingSuitColor,
|
||||
} from '../types/AvatarGenerationRequest';
|
||||
import { MediaUrl } from '../value-objects/MediaUrl';
|
||||
|
||||
export class AvatarGenerationRequest implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly facePhotoUrl: string;
|
||||
readonly facePhotoUrl: MediaUrl;
|
||||
readonly suitColor: RacingSuitColor;
|
||||
readonly style: AvatarStyle;
|
||||
private _status: AvatarGenerationStatus;
|
||||
private _generatedAvatarUrls: string[];
|
||||
private _generatedAvatarUrls: MediaUrl[];
|
||||
private _selectedAvatarIndex?: number;
|
||||
private _errorMessage?: string;
|
||||
readonly createdAt: Date;
|
||||
@@ -58,11 +29,11 @@ export class AvatarGenerationRequest {
|
||||
private constructor(props: AvatarGenerationRequestProps) {
|
||||
this.id = props.id;
|
||||
this.userId = props.userId;
|
||||
this.facePhotoUrl = props.facePhotoUrl;
|
||||
this.facePhotoUrl = MediaUrl.create(props.facePhotoUrl);
|
||||
this.suitColor = props.suitColor;
|
||||
this.style = props.style;
|
||||
this._status = props.status;
|
||||
this._generatedAvatarUrls = [...props.generatedAvatarUrls];
|
||||
this._generatedAvatarUrls = props.generatedAvatarUrls.map(url => MediaUrl.create(url));
|
||||
this._selectedAvatarIndex = props.selectedAvatarIndex;
|
||||
this._errorMessage = props.errorMessage;
|
||||
this.createdAt = props.createdAt;
|
||||
@@ -106,7 +77,7 @@ export class AvatarGenerationRequest {
|
||||
}
|
||||
|
||||
get generatedAvatarUrls(): string[] {
|
||||
return [...this._generatedAvatarUrls];
|
||||
return this._generatedAvatarUrls.map(url => url.value);
|
||||
}
|
||||
|
||||
get selectedAvatarIndex(): number | undefined {
|
||||
@@ -115,7 +86,7 @@ export class AvatarGenerationRequest {
|
||||
|
||||
get selectedAvatarUrl(): string | undefined {
|
||||
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
|
||||
return this._generatedAvatarUrls[this._selectedAvatarIndex];
|
||||
return this._generatedAvatarUrls[this._selectedAvatarIndex].value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -149,7 +120,7 @@ export class AvatarGenerationRequest {
|
||||
throw new Error('At least one avatar URL is required');
|
||||
}
|
||||
this._status = 'completed';
|
||||
this._generatedAvatarUrls = [...avatarUrls];
|
||||
this._generatedAvatarUrls = avatarUrls.map(url => MediaUrl.create(url));
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
@@ -204,11 +175,11 @@ export class AvatarGenerationRequest {
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
facePhotoUrl: this.facePhotoUrl,
|
||||
facePhotoUrl: this.facePhotoUrl.value,
|
||||
suitColor: this.suitColor,
|
||||
style: this.style,
|
||||
status: this._status,
|
||||
generatedAvatarUrls: [...this._generatedAvatarUrls],
|
||||
generatedAvatarUrls: this._generatedAvatarUrls.map(url => url.value),
|
||||
selectedAvatarIndex: this._selectedAvatarIndex,
|
||||
errorMessage: this._errorMessage,
|
||||
createdAt: this.createdAt,
|
||||
|
||||
44
packages/media/domain/types/AvatarGenerationRequest.ts
Normal file
44
packages/media/domain/types/AvatarGenerationRequest.ts
Normal 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;
|
||||
}
|
||||
36
packages/media/domain/value-objects/MediaUrl.test.ts
Normal file
36
packages/media/domain/value-objects/MediaUrl.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/media/domain/value-objects/MediaUrl.ts
Normal file
46
packages/media/domain/value-objects/MediaUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,8 @@ export type {
|
||||
NotificationAction,
|
||||
} from '../domain/entities/Notification';
|
||||
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export type { NotificationType } from '../domain/value-objects/NotificationType';
|
||||
export type { NotificationChannel } from '../domain/value-objects/NotificationChannel';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType';
|
||||
export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel';
|
||||
export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes';
|
||||
|
||||
// Re-export repository interfaces
|
||||
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface NotificationDeliveryResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
@@ -12,7 +13,7 @@ export interface UnreadNotificationsResult {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsUseCase {
|
||||
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Marks a notification as read.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
@@ -12,7 +13,7 @@ export interface MarkNotificationReadCommand {
|
||||
recipientId: string; // For validation
|
||||
}
|
||||
|
||||
export class MarkNotificationReadUseCase {
|
||||
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
@@ -42,7 +43,7 @@ export class MarkNotificationReadUseCase {
|
||||
*
|
||||
* Marks all notifications as read for a recipient.
|
||||
*/
|
||||
export class MarkAllNotificationsReadUseCase {
|
||||
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
@@ -62,7 +63,7 @@ export interface DismissNotificationCommand {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export class DismissNotificationUseCase {
|
||||
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
*/
|
||||
export class GetNotificationPreferencesQuery {
|
||||
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -32,7 +33,7 @@ export interface UpdateChannelPreferenceCommand {
|
||||
preference: ChannelPreference;
|
||||
}
|
||||
|
||||
export class UpdateChannelPreferenceUseCase {
|
||||
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -53,7 +54,7 @@ export interface UpdateTypePreferenceCommand {
|
||||
preference: TypePreference;
|
||||
}
|
||||
|
||||
export class UpdateTypePreferenceUseCase {
|
||||
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -74,7 +75,7 @@ export interface UpdateQuietHoursCommand {
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export class UpdateQuietHoursUseCase {
|
||||
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -103,7 +104,7 @@ export interface SetDigestModeCommand {
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export class SetDigestModeUseCase {
|
||||
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
*/
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationData } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
@@ -43,7 +43,7 @@ export interface SendNotificationResult {
|
||||
deliveryResults: NotificationDeliveryResult[];
|
||||
}
|
||||
|
||||
export class SendNotificationUseCase {
|
||||
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { NotificationId } from '../value-objects/NotificationId';
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
|
||||
|
||||
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required';
|
||||
|
||||
@@ -54,7 +55,7 @@ export interface NotificationAction {
|
||||
}
|
||||
|
||||
export interface NotificationProps {
|
||||
id: string;
|
||||
id: NotificationId;
|
||||
/** Driver who receives this notification */
|
||||
recipientId: string;
|
||||
/** Type of notification */
|
||||
@@ -85,15 +86,17 @@ export interface NotificationProps {
|
||||
respondedAt?: Date;
|
||||
}
|
||||
|
||||
export class Notification {
|
||||
export class Notification implements IEntity<string> {
|
||||
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;
|
||||
createdAt?: Date;
|
||||
urgency?: NotificationUrgency;
|
||||
}): Notification {
|
||||
if (!props.id) throw new NotificationDomainError('Notification ID is required');
|
||||
const id = NotificationId.create(props.id);
|
||||
|
||||
if (!props.recipientId) throw new NotificationDomainError('Recipient ID is required');
|
||||
if (!props.type) throw new NotificationDomainError('Notification type is required');
|
||||
if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required');
|
||||
@@ -105,13 +108,14 @@ export class Notification {
|
||||
|
||||
return new Notification({
|
||||
...props,
|
||||
id,
|
||||
status: props.status ?? defaultStatus,
|
||||
urgency: props.urgency ?? 'silent',
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get id(): string { return this.props.id.value; }
|
||||
get recipientId(): string { return this.props.recipientId; }
|
||||
get type(): NotificationType { return this.props.type; }
|
||||
get title(): string { return this.props.title; }
|
||||
@@ -210,7 +214,10 @@ export class Notification {
|
||||
/**
|
||||
* Convert to plain object for serialization
|
||||
*/
|
||||
toJSON(): NotificationProps {
|
||||
return { ...this.props };
|
||||
toJSON(): Omit<NotificationProps, 'id'> & { id: string } {
|
||||
return {
|
||||
...this.props,
|
||||
id: this.props.id.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
* Represents a user's notification preferences for different channels and types.
|
||||
*/
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
|
||||
import { QuietHours } from '../value-objects/QuietHours';
|
||||
|
||||
export interface ChannelPreference {
|
||||
/** Whether this channel is enabled */
|
||||
@@ -24,6 +24,8 @@ export interface TypePreference {
|
||||
}
|
||||
|
||||
export interface NotificationPreferenceProps {
|
||||
/** Aggregate ID for this preference (usually same as driverId) */
|
||||
id: string;
|
||||
/** Driver ID this preference belongs to */
|
||||
driverId: string;
|
||||
/** Global channel preferences */
|
||||
@@ -42,10 +44,13 @@ export interface NotificationPreferenceProps {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class NotificationPreference {
|
||||
export class NotificationPreference implements IEntity<string> {
|
||||
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.channels) throw new NotificationDomainError('Channel preferences are required');
|
||||
|
||||
@@ -60,6 +65,7 @@ export class NotificationPreference {
|
||||
*/
|
||||
static createDefault(driverId: string): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
id: driverId,
|
||||
driverId,
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
@@ -72,6 +78,7 @@ export class NotificationPreference {
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get driverId(): string { return this.props.driverId; }
|
||||
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
|
||||
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
|
||||
@@ -83,6 +90,13 @@ export class NotificationPreference {
|
||||
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
|
||||
get updatedAt(): Date { return this.props.updatedAt; }
|
||||
|
||||
get quietHours(): QuietHours | undefined {
|
||||
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return QuietHours.create(this.props.quietHoursStart, this.props.quietHoursEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific channel is enabled
|
||||
*/
|
||||
@@ -117,20 +131,13 @@ export class NotificationPreference {
|
||||
* Check if current time is in quiet hours
|
||||
*/
|
||||
isInQuietHours(): boolean {
|
||||
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
|
||||
const quietHours = this.quietHours;
|
||||
if (!quietHours) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
|
||||
if (this.props.quietHoursStart < this.props.quietHoursEnd) {
|
||||
// Normal range (e.g., 22:00 to 07:00 next day is NOT this case)
|
||||
return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd;
|
||||
} else {
|
||||
// Overnight range (e.g., 22:00 to 07:00)
|
||||
return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd;
|
||||
}
|
||||
return quietHours.containsHour(now.getHours());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +172,12 @@ export class NotificationPreference {
|
||||
* Update quiet hours
|
||||
*/
|
||||
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
|
||||
const validated = start === undefined || end === undefined ? undefined : QuietHours.create(start, end);
|
||||
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
quietHoursStart: start,
|
||||
quietHoursEnd: end,
|
||||
quietHoursStart: validated?.props.startHour,
|
||||
quietHoursEnd: validated?.props.endHour,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export class NotificationDomainError extends Error {
|
||||
readonly name: string = 'NotificationDomainError';
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
constructor(message: string) {
|
||||
/**
|
||||
* Domain Error: NotificationDomainError
|
||||
*
|
||||
* Implements the shared IDomainError contract for notification domain failures.
|
||||
*/
|
||||
export class NotificationDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
|
||||
readonly name = 'NotificationDomainError';
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'notifications';
|
||||
readonly kind: CommonDomainErrorKind;
|
||||
|
||||
constructor(message: string, kind: CommonDomainErrorKind = 'validation') {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../entities/Notification';
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationType } from '../types/NotificationTypes';
|
||||
|
||||
export interface INotificationRepository {
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export type NotificationType =
|
||||
// Protest-related
|
||||
| 'protest_filed' // A protest was filed against you
|
||||
| 'protest_filed' // A protest was filed against you
|
||||
| 'protest_defense_requested' // Steward requests your defense
|
||||
| 'protest_defense_submitted' // Accused submitted their defense
|
||||
| 'protest_comment_added' // New comment on a protest you're involved in
|
||||
| 'protest_vote_required' // You need to vote on a protest
|
||||
| 'protest_vote_cast' // Someone voted on a protest
|
||||
| 'protest_resolved' // Protest has been resolved
|
||||
| 'protest_comment_added' // New comment on a protest you're involved in
|
||||
| 'protest_vote_required' // You need to vote on a protest
|
||||
| 'protest_vote_cast' // Someone voted on a protest
|
||||
| 'protest_resolved' // Protest has been resolved
|
||||
// Penalty-related
|
||||
| 'penalty_issued' // A penalty was issued to you
|
||||
| 'penalty_appealed' // Penalty appeal submitted
|
||||
| 'penalty_issued' // A penalty was issued to you
|
||||
| 'penalty_appealed' // Penalty appeal submitted
|
||||
| 'penalty_appeal_resolved' // Appeal was resolved
|
||||
// Race-related
|
||||
| 'race_registration_open' // Race registration is now open
|
||||
| 'race_reminder' // Race starting soon reminder
|
||||
| 'race_results_posted' // Race results are available
|
||||
| 'race_registration_open' // Race registration is now open
|
||||
| 'race_reminder' // Race starting soon reminder
|
||||
| 'race_results_posted' // Race results are available
|
||||
// League-related
|
||||
| 'league_invite' // You were invited to a league
|
||||
| 'league_join_request' // Someone requested to join your league
|
||||
| 'league_join_approved' // Your join request was approved
|
||||
| 'league_join_rejected' // Your join request was rejected
|
||||
| 'league_role_changed' // Your role in a league changed
|
||||
| 'league_invite' // You were invited to a league
|
||||
| 'league_join_request' // Someone requested to join your league
|
||||
| 'league_join_approved' // Your join request was approved
|
||||
| 'league_join_rejected' // Your join request was rejected
|
||||
| 'league_role_changed' // Your role in a league changed
|
||||
// Team-related
|
||||
| 'team_invite' // You were invited to a team
|
||||
| 'team_join_request' // Someone requested to join your team
|
||||
| 'team_join_approved' // Your team join request was approved
|
||||
| 'team_invite' // You were invited to a team
|
||||
| 'team_join_request' // Someone requested to join your team
|
||||
| 'team_join_approved' // Your team join request was approved
|
||||
// Sponsorship-related
|
||||
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
|
||||
| 'sponsorship_request_accepted' // Your sponsorship request was accepted
|
||||
| 'sponsorship_request_rejected' // Your sponsorship request was rejected
|
||||
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
|
||||
| 'sponsorship_request_accepted' // Your sponsorship request was accepted
|
||||
| 'sponsorship_request_rejected' // Your sponsorship request was rejected
|
||||
| 'sponsorship_request_withdrawn' // A sponsor withdrew their request
|
||||
| 'sponsorship_activated' // Sponsorship is now active
|
||||
| 'sponsorship_payment_received' // Payment received for sponsorship
|
||||
| 'sponsorship_activated' // Sponsorship is now active
|
||||
| 'sponsorship_payment_received' // Payment received for sponsorship
|
||||
// System
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
|
||||
/**
|
||||
* Get human-readable title for notification type
|
||||
@@ -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'];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
72
packages/notifications/domain/value-objects/QuietHours.ts
Normal file
72
packages/notifications/domain/value-objects/QuietHours.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface DiscordAdapterConfig {
|
||||
webhookUrl?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface EmailAdapterConfig {
|
||||
smtpHost?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export class InAppNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'in_app';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
INotificationGatewayRegistry,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export class InMemoryNotificationRepository implements INotificationRepository {
|
||||
private notifications: Map<string, Notification> = new Map();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
||||
import type { ParticipantRef } from '@gridpilot/racing/domain/types/ParticipantRef';
|
||||
|
||||
export interface ChampionshipStandingsRowDTO {
|
||||
participant: ParticipantRef;
|
||||
|
||||
@@ -53,9 +53,9 @@ export interface LeagueTimingsFormDTO {
|
||||
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin", or "track" for track local time
|
||||
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
intervalWeeks?: number;
|
||||
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];
|
||||
weekdays?: import('../../domain/types/Weekday').Weekday[];
|
||||
monthlyOrdinal?: 1 | 2 | 3 | 4;
|
||||
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
|
||||
monthlyWeekday?: import('../../domain/types/Weekday').Weekday;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
|
||||
import type { Weekday } from '../../domain/value-objects/Weekday';
|
||||
import type { Weekday } from '../../domain/types/Weekday';
|
||||
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
|
||||
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
|
||||
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
|
||||
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import type { RecurrenceStrategy } from '../../domain/types/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/types/RecurrenceStrategy';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Team, TeamJoinRequest, TeamMembership } from '../../domain/entities/Team';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type {
|
||||
TeamJoinRequest,
|
||||
TeamMembership,
|
||||
} from '../../domain/types/TeamMembership';
|
||||
|
||||
export interface JoinTeamCommandDTO {
|
||||
teamId: string;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export abstract class RacingApplicationError extends Error {
|
||||
import type { IApplicationError, CommonApplicationErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
export abstract class RacingApplicationError
|
||||
extends Error
|
||||
implements IApplicationError<CommonApplicationErrorKind | string, unknown>
|
||||
{
|
||||
readonly type = 'application' as const;
|
||||
readonly context = 'racing-application';
|
||||
abstract readonly kind: CommonApplicationErrorKind | string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -22,11 +29,16 @@ export interface EntityNotFoundDetails {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class EntityNotFoundError extends RacingApplicationError {
|
||||
export class EntityNotFoundError
|
||||
extends RacingApplicationError
|
||||
implements IApplicationError<'not_found', EntityNotFoundDetails>
|
||||
{
|
||||
readonly kind = 'not_found' as const;
|
||||
readonly details: EntityNotFoundDetails;
|
||||
|
||||
constructor(public readonly details: EntityNotFoundDetails) {
|
||||
constructor(details: EntityNotFoundDetails) {
|
||||
super(`${details.entity} not found for id: ${details.id}`);
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +51,25 @@ export type PermissionDeniedReason =
|
||||
| 'TEAM_OWNER_CANNOT_LEAVE'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export class PermissionDeniedError extends RacingApplicationError {
|
||||
export class PermissionDeniedError
|
||||
extends RacingApplicationError
|
||||
implements IApplicationError<'forbidden', PermissionDeniedReason>
|
||||
{
|
||||
readonly kind = 'forbidden' as const;
|
||||
|
||||
constructor(public readonly reason: PermissionDeniedReason, message?: string) {
|
||||
super(message ?? `Permission denied: ${reason}`);
|
||||
}
|
||||
|
||||
get details(): PermissionDeniedReason {
|
||||
return this.reason;
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessRuleViolationError extends RacingApplicationError {
|
||||
export class BusinessRuleViolationError
|
||||
extends RacingApplicationError
|
||||
implements IApplicationError<'conflict', undefined>
|
||||
{
|
||||
readonly kind = 'conflict' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
|
||||
@@ -54,13 +54,13 @@ export * from './ports/DriverRatingProvider';
|
||||
|
||||
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
||||
|
||||
export type { Team } from '../domain/entities/Team';
|
||||
export type {
|
||||
Team,
|
||||
TeamMembership,
|
||||
TeamJoinRequest,
|
||||
TeamRole,
|
||||
TeamMembershipStatus,
|
||||
} from '../domain/entities/Team';
|
||||
} from '../domain/types/TeamMembership';
|
||||
|
||||
export type { DriverDTO } from './dto/DriverDTO';
|
||||
export type { LeagueDTO } from './dto/LeagueDTO';
|
||||
|
||||
12
packages/racing/application/ports/IImageServicePort.ts
Normal file
12
packages/racing/application/ports/IImageServicePort.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Team, TeamMembership } from '../../domain/entities/Team';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||
|
||||
export interface DriverTeamViewModel {
|
||||
team: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Team, TeamMembership } from '../../domain/entities/Team';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||
|
||||
export interface TeamDetailsViewModel {
|
||||
team: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeamJoinRequest } from '../../domain/entities/Team';
|
||||
import type { TeamJoinRequest } from '../../domain/types/TeamMembership';
|
||||
|
||||
export interface TeamJoinRequestViewModel {
|
||||
requestId: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeamMembership } from '../../domain/entities/Team';
|
||||
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||
|
||||
export interface TeamMemberViewModel {
|
||||
driverId: string;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export interface AcceptSponsorshipRequestDTO {
|
||||
requestId: string;
|
||||
@@ -23,7 +24,8 @@ export interface AcceptSponsorshipRequestResultDTO {
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
export class AcceptSponsorshipRequestUseCase {
|
||||
export class AcceptSponsorshipRequestUseCase
|
||||
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> {
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import { Money, type Currency } from '../../domain/value-objects/Money';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import {
|
||||
EntityNotFoundError,
|
||||
BusinessRuleViolationError,
|
||||
@@ -31,8 +32,10 @@ export interface ApplyForSponsorshipResultDTO {
|
||||
status: 'pending';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class ApplyForSponsorshipUseCase {
|
||||
|
||||
export class ApplyForSponsorshipUseCase
|
||||
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO>
|
||||
{
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export interface ApplyPenaltyCommand {
|
||||
raceId: string;
|
||||
@@ -23,7 +24,8 @@ export interface ApplyPenaltyCommand {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ApplyPenaltyUseCase {
|
||||
export class ApplyPenaltyUseCase
|
||||
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> {
|
||||
constructor(
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
private readonly protestRepository: IProtestRepository,
|
||||
|
||||
@@ -4,10 +4,12 @@ import type {
|
||||
TeamMembershipStatus,
|
||||
TeamRole,
|
||||
TeamJoinRequest,
|
||||
} from '../../domain/entities/Team';
|
||||
} from '../../domain/types/TeamMembership';
|
||||
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export class ApproveTeamJoinRequestUseCase {
|
||||
export class ApproveTeamJoinRequestUseCase
|
||||
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> {
|
||||
constructor(
|
||||
private readonly membershipRepository: ITeamMembershipRepository,
|
||||
) {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case: CancelRaceUseCase
|
||||
@@ -13,7 +14,8 @@ export interface CancelRaceCommandDTO {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export class CancelRaceUseCase {
|
||||
export class CancelRaceUseCase
|
||||
implements AsyncUseCase<CancelRaceCommandDTO, void> {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
) {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
@@ -47,7 +48,8 @@ export interface CreateLeagueWithSeasonAndScoringResultDTO {
|
||||
scoringPresetName?: string;
|
||||
}
|
||||
|
||||
export class CreateLeagueWithSeasonAndScoringUseCase {
|
||||
export class CreateLeagueWithSeasonAndScoringUseCase
|
||||
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import { Team } from '../../domain/entities/Team';
|
||||
import type {
|
||||
Team,
|
||||
TeamMembership,
|
||||
TeamMembershipStatus,
|
||||
TeamRole,
|
||||
} from '../../domain/entities/Team';
|
||||
} from '../../domain/types/TeamMembership';
|
||||
import type {
|
||||
CreateTeamCommandDTO,
|
||||
CreateTeamResultDTO,
|
||||
|
||||
@@ -5,12 +5,15 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
|
||||
import type { IAllLeaguesWithCapacityAndScoringPresenter, LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving all leagues with capacity and scoring information.
|
||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||
*/
|
||||
export class GetAllLeaguesWithCapacityAndScoringUseCase {
|
||||
export class GetAllLeaguesWithCapacityAndScoringUseCase
|
||||
implements AsyncUseCase<void, void>
|
||||
{
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IAllLeaguesWithCapacityPresenter } from '../presenters/IAllLeaguesWithCapacityPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving all leagues with capacity information.
|
||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||
*/
|
||||
export class GetAllLeaguesWithCapacityUseCase {
|
||||
export class GetAllLeaguesWithCapacityUseCase
|
||||
implements AsyncUseCase<void, void>
|
||||
{
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
AllRacesListItemViewModel,
|
||||
AllRacesFilterOptionsViewModel,
|
||||
} from '../presenters/IAllRacesPagePresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export class GetAllRacesPageDataUseCase {
|
||||
export class GetAllRacesPageDataUseCase
|
||||
implements AsyncUseCase<void, void> {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { IAllTeamsPresenter } from '../presenters/IAllTeamsPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving all teams.
|
||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||
*/
|
||||
export class GetAllTeamsUseCase {
|
||||
export class GetAllTeamsUseCase
|
||||
implements AsyncUseCase<void, void> {
|
||||
constructor(
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IImageService } from '../../domain/services/IImageService';
|
||||
import type { IImageServicePort } from '../ports/IImageServicePort';
|
||||
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type {
|
||||
IDashboardOverviewPresenter,
|
||||
DashboardOverviewViewModel,
|
||||
@@ -33,7 +34,8 @@ export interface GetDashboardOverviewParams {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
export class GetDashboardOverviewUseCase {
|
||||
export class GetDashboardOverviewUseCase
|
||||
implements AsyncUseCase<GetDashboardOverviewParams, void> {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
@@ -44,7 +46,7 @@ export class GetDashboardOverviewUseCase {
|
||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||
private readonly feedRepository: IFeedRepository,
|
||||
private readonly socialRepository: ISocialGraphRepository,
|
||||
private readonly imageService: IImageService,
|
||||
private readonly imageService: IImageServicePort,
|
||||
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
|
||||
public readonly presenter: IDashboardOverviewPresenter,
|
||||
) {}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { IDriverTeamPresenter } from '../presenters/IDriverTeamPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving a driver's team.
|
||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||
*/
|
||||
export class GetDriverTeamUseCase {
|
||||
export class GetDriverTeamUseCase
|
||||
implements AsyncUseCase<string, boolean> {
|
||||
constructor(
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly membershipRepository: ITeamMembershipRepository,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||
import type { IImageService } from '../../domain/services/IImageService';
|
||||
import type { IImageServicePort } from '../ports/IImageServicePort';
|
||||
import type { IDriversLeaderboardPresenter } from '../presenters/IDriversLeaderboardPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving driver leaderboard data.
|
||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||
*/
|
||||
export class GetDriversLeaderboardUseCase {
|
||||
export class GetDriversLeaderboardUseCase
|
||||
implements AsyncUseCase<void, void> {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly rankingService: IRankingService,
|
||||
private readonly driverStatsService: IDriverStatsService,
|
||||
private readonly imageService: IImageService,
|
||||
private readonly imageService: IImageServicePort,
|
||||
public readonly presenter: IDriversLeaderboardPresenter,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISe
|
||||
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
export interface GetEntitySponsorshipPricingDTO {
|
||||
entityType: SponsorableEntityType;
|
||||
@@ -38,7 +39,8 @@ export interface GetEntitySponsorshipPricingResultDTO {
|
||||
secondarySlot?: SponsorshipSlotDTO;
|
||||
}
|
||||
|
||||
export class GetEntitySponsorshipPricingUseCase {
|
||||
export class GetEntitySponsorshipPricingUseCase
|
||||
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, void> {
|
||||
constructor(
|
||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user