This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

View File

@@ -11,16 +11,18 @@ import type {
EngagementEntityType,
EngagementEventProps,
} from '../types/EngagementEvent';
export type { EngagementAction, EngagementEntityType } from '../types/EngagementEvent';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class EngagementEvent implements IEntity<string> {
readonly id: string;
readonly action: EngagementAction;
readonly entityType: EngagementEntityType;
readonly actorId?: string;
readonly actorId: string | undefined;
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
readonly sessionId: string;
readonly metadata?: Record<string, string | number | boolean>;
readonly metadata: Record<string, string | number | boolean> | undefined;
readonly timestamp: Date;
private readonly entityIdVo: AnalyticsEntityId;

View File

@@ -7,19 +7,21 @@
import type { IEntity } from '@gridpilot/shared/domain';
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
export type { EntityType, VisitorType } from '../types/PageView';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
import { PageViewId } from '../value-objects/PageViewId';
export class PageView implements IEntity<string> {
readonly entityType: EntityType;
readonly visitorId?: string;
readonly visitorId: string | undefined;
readonly visitorType: VisitorType;
readonly referrer?: string;
readonly userAgent?: string;
readonly country?: string;
readonly referrer: string | undefined;
readonly userAgent: string | undefined;
readonly country: string | undefined;
readonly timestamp: Date;
readonly durationMs?: number;
readonly durationMs: number | undefined;
private readonly idVo: PageViewId;
private readonly entityIdVo: AnalyticsEntityId;

View File

@@ -63,8 +63,11 @@ export class PlaywrightAuthSessionService implements AuthenticationServicePort {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
// ===== Helpers =====

View File

@@ -609,8 +609,11 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private syncSessionStateFromBrowser(): void {
@@ -758,7 +761,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO> {
const stepNumber = stepId.value;
const skipFixtureNavigation =
(config as any).__skipFixtureNavigation === true;
(config as { __skipFixtureNavigation?: unknown }).__skipFixtureNavigation === true;
if (!skipFixtureNavigation) {
if (!this.isRealMode() && this.config.baseUrl) {
@@ -2292,9 +2295,9 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
await this.page.evaluate(({ sel, val }) => {
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
(el as any).value = val;
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -2492,11 +2495,11 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
// If element is a checkbox/input, set checked; otherwise try to toggle aria-checked or click
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Fallback: set aria-checked attribute and dispatch click
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
try { (el as HTMLElement).click(); } catch { /* ignore */ }
}
} catch {
@@ -2997,7 +3000,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
* Get the source of the browser mode configuration.
*/
getBrowserModeSource(): 'env' | 'file' | 'default' {
return this.browserSession.getBrowserModeSource() as any;
return this.browserSession.getBrowserModeSource() as 'env' | 'file' | 'default';
}
/**

View File

@@ -48,8 +48,11 @@ export class PlaywrightBrowserSession {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private isRealMode(): boolean {
@@ -122,8 +125,10 @@ export class PlaywrightBrowserSession {
this.browserModeSource = currentConfig.source as BrowserModeSource;
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
const adapterAny = PlaywrightAutomationAdapter as any;
const launcher = adapterAny.testLauncher ?? chromium;
const adapterWithLauncher = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: typeof chromium;
};
const launcher = adapterWithLauncher.testLauncher ?? chromium;
this.log('debug', 'Effective browser mode at connect', {
effectiveMode,

View File

@@ -108,8 +108,11 @@ export class WizardStepOrchestrator {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private async waitIfPaused(): Promise<void> {
@@ -345,7 +348,7 @@ export class WizardStepOrchestrator {
{ selector: raceInfoFallback },
);
const inner = await this.page!.evaluate(() => {
const doc = (globalThis as any).document as any;
const doc = (globalThis as { document?: Document }).document;
return (
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
);
@@ -428,32 +431,32 @@ export class WizardStepOrchestrator {
const page = this.page;
if (page) {
await page.evaluate((term) => {
const doc = (globalThis as any).document as any;
const doc = (globalThis as { document?: Document }).document;
if (!doc) {
return;
}
const root =
(doc.querySelector('#set-admins') as any) ?? doc.body;
(doc.querySelector('#set-admins') as HTMLElement | null) ?? doc.body;
if (!root) {
return;
}
const rows = Array.from(
(root as any).querySelectorAll(
root.querySelectorAll<HTMLTableRowElement>(
'tbody[data-testid="admin-display-name-list"] tr',
),
) as any[];
);
if (rows.length === 0) {
return;
}
const needle = String(term).toLowerCase();
for (const r of rows) {
const text = String((r as any).textContent || '').toLowerCase();
const text = String(r.textContent || '').toLowerCase();
if (text.includes(needle)) {
(r as any).setAttribute('data-selected-admin', 'true');
r.setAttribute('data-selected-admin', 'true');
return;
}
}
(rows[0] as any).setAttribute('data-selected-admin', 'true');
rows[0]?.setAttribute('data-selected-admin', 'true');
}, String(adminSearch));
}
}
@@ -975,7 +978,7 @@ export class WizardStepOrchestrator {
{ selector: weatherFallbackSelector },
);
const inner = await this.page!.evaluate(() => {
const doc = (globalThis as any).document as any;
const doc = (globalThis as { document?: Document }).document;
return (
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
);
@@ -1130,7 +1133,7 @@ export class WizardStepOrchestrator {
} else {
const valueStr = String(config.trackState);
await this.page!.evaluate((trackStateValue) => {
const doc = (globalThis as any).document as any;
const doc = (globalThis as { document?: Document }).document;
if (!doc) {
return;
}
@@ -1145,27 +1148,24 @@ export class WizardStepOrchestrator {
};
const numeric = map[trackStateValue] ?? null;
const inputs = Array.from(
doc.querySelectorAll(
doc.querySelectorAll<HTMLInputElement>(
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
),
) as any[];
);
if (numeric !== null && inputs.length > 0) {
for (const inp of inputs) {
try {
(inp as any).value = String(numeric);
const ds =
(inp as any).dataset || ((inp as any).dataset = {});
ds.value = String(numeric);
(inp as any).setAttribute?.(
inp.value = String(numeric);
inp.dataset.value = String(numeric);
inp.setAttribute(
'data-value',
String(numeric),
);
const Ev = (globalThis as any).Event;
(inp as any).dispatchEvent?.(
new Ev('input', { bubbles: true }),
inp.dispatchEvent(
new Event('input', { bubbles: true }),
);
(inp as any).dispatchEvent?.(
new Ev('change', { bubbles: true }),
inp.dispatchEvent(
new Event('change', { bubbles: true }),
);
} catch {
}

View File

@@ -22,8 +22,11 @@ export class IRacingDomInteractor {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private isRealMode(): boolean {
@@ -86,7 +89,7 @@ export class IRacingDomInteractor {
});
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
(el as any).value = val;
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
@@ -194,9 +197,9 @@ export class IRacingDomInteractor {
await page.evaluate(({ sel, val }) => {
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
(el as any).value = val;
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -372,8 +375,8 @@ export class IRacingDomInteractor {
const tag = await page
.locator(h)
.first()
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
.evaluate((el: Element) =>
String(el.tagName || '').toLowerCase(),
)
.catch(() => '');
if (tag === 'select') {
@@ -511,10 +514,10 @@ export class IRacingDomInteractor {
try {
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
try {
(el as HTMLElement).click();
} catch {
@@ -544,8 +547,8 @@ export class IRacingDomInteractor {
if (count === 0) continue;
const tagName = await locator
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
.evaluate((el: Element) =>
String(el.tagName || '').toLowerCase(),
)
.catch(() => '');
const type = await locator.getAttribute('type').catch(() => '');
@@ -682,8 +685,8 @@ export class IRacingDomInteractor {
if (count === 0) continue;
const tagName = await locator
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
.evaluate((el: Element) =>
String(el.tagName || '').toLowerCase(),
)
.catch(() => '');
if (tagName === 'input') {

View File

@@ -32,8 +32,11 @@ export class IRacingDomNavigator {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private isRealMode(): boolean {

View File

@@ -15,8 +15,11 @@ export class SafeClickService {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
private isRealMode(): boolean {

View File

@@ -6,6 +6,7 @@ import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO'
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../IAutomationLifecycleEmitter';
interface MockConfig {
simulateFailures?: boolean;
@@ -24,9 +25,10 @@ interface StepExecutionResult {
};
}
export class MockBrowserAutomationAdapter implements IBrowserAutomation {
export class MockBrowserAutomationAdapter implements IBrowserAutomation, IAutomationLifecycleEmitter {
private config: MockConfig;
private connected: boolean = false;
private lifecycleCallbacks: Set<LifecycleCallback> = new Set();
constructor(config: MockConfig = {}) {
this.config = {
@@ -105,6 +107,13 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO> {
// Emit a simple lifecycle event for tests/overlay sync
await this.emitLifecycle({
type: 'action-started',
actionId: String(stepId.value),
timestamp: Date.now(),
payload: { config },
});
if (this.shouldSimulateFailure()) {
throw new Error(`Simulated failure at step ${stepId.value}`);
}
@@ -154,4 +163,18 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
}
return Math.random() < (this.config.failureRate || 0.1);
}
onLifecycle(cb: LifecycleCallback): void {
this.lifecycleCallbacks.add(cb);
}
offLifecycle(cb: LifecycleCallback): void {
this.lifecycleCallbacks.delete(cb);
}
private async emitLifecycle(event: Parameters<LifecycleCallback>[0]): Promise<void> {
for (const cb of Array.from(this.lifecycleCallbacks)) {
await cb(event);
}
}
}

View File

@@ -7,6 +7,8 @@
"exports": {
"./domain/*": "./domain/*",
"./application/*": "./application/*",
"./infrastructure/adapters/automation": "./infrastructure/adapters/automation/index.ts",
"./infrastructure/config": "./infrastructure/config/index.ts",
"./infrastructure/*": "./infrastructure/*"
},
"dependencies": {}

View File

@@ -39,13 +39,17 @@ export class LoginWithEmailUseCase {
}
// Create session
const authenticatedUser: AuthenticatedUserDTO = {
const authenticatedUserBase: AuthenticatedUserDTO = {
id: user.id,
displayName: user.displayName,
email: user.email,
primaryDriverId: user.primaryDriverId,
};
const authenticatedUser: AuthenticatedUserDTO =
user.primaryDriverId !== undefined
? { ...authenticatedUserBase, primaryDriverId: user.primaryDriverId }
: authenticatedUserBase;
return this.sessionPort.createSession(authenticatedUser);
}

View File

@@ -71,7 +71,6 @@ export class SignupWithEmailUseCase {
id: newUser.id,
displayName: newUser.displayName,
email: newUser.email,
primaryDriverId: undefined, // Will be set during onboarding
};
const session = await this.sessionPort.createSession(authenticatedUser);

View File

@@ -4,7 +4,7 @@
* Defines the contract for AI-powered avatar generation.
*/
import type { RacingSuitColor, AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
import type { RacingSuitColor, AvatarStyle } from '../../domain/types/AvatarGenerationRequest';
export interface AvatarGenerationOptions {
facePhotoUrl: string;

View File

@@ -42,7 +42,7 @@ export class RequestAvatarGenerationUseCase
userId: command.userId,
facePhotoUrl: `data:image/jpeg;base64,${command.facePhotoData}`,
suitColor: command.suitColor,
style: command.style,
...(command.style ? { style: command.style } : {}),
});
// Mark as validating

View File

@@ -53,10 +53,13 @@ export class SelectAvatarUseCase
request.selectAvatar(command.avatarIndex);
await this.avatarRepository.save(request);
return {
success: true,
selectedAvatarUrl: request.selectedAvatarUrl,
};
const selectedAvatarUrl = request.selectedAvatarUrl;
const result: SelectAvatarResult =
selectedAvatarUrl !== undefined
? { success: true, selectedAvatarUrl }
: { success: true };
return result;
} catch (error) {
return {
success: false,

View File

@@ -34,8 +34,12 @@ export class AvatarGenerationRequest implements IEntity<string> {
this.style = props.style;
this._status = props.status;
this._generatedAvatarUrls = props.generatedAvatarUrls.map(url => MediaUrl.create(url));
this._selectedAvatarIndex = props.selectedAvatarIndex;
this._errorMessage = props.errorMessage;
if (props.selectedAvatarIndex !== undefined) {
this._selectedAvatarIndex = props.selectedAvatarIndex;
}
if (props.errorMessage !== undefined) {
this._errorMessage = props.errorMessage;
}
this.createdAt = props.createdAt;
this._updatedAt = props.updatedAt;
}
@@ -85,10 +89,15 @@ export class AvatarGenerationRequest implements IEntity<string> {
}
get selectedAvatarUrl(): string | undefined {
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
return this._generatedAvatarUrls[this._selectedAvatarIndex].value;
const index = this._selectedAvatarIndex;
if (index === undefined) {
return undefined;
}
return undefined;
const avatar = this._generatedAvatarUrls[index];
if (!avatar) {
return undefined;
}
return avatar.value;
}
get errorMessage(): string | undefined {
@@ -172,7 +181,7 @@ export class AvatarGenerationRequest implements IEntity<string> {
}
toProps(): AvatarGenerationRequestProps {
return {
const base: AvatarGenerationRequestProps = {
id: this.id,
userId: this.userId,
facePhotoUrl: this.facePhotoUrl.value,
@@ -180,10 +189,18 @@ export class AvatarGenerationRequest implements IEntity<string> {
style: this.style,
status: this._status,
generatedAvatarUrls: this._generatedAvatarUrls.map(url => url.value),
selectedAvatarIndex: this._selectedAvatarIndex,
errorMessage: this._errorMessage,
createdAt: this.createdAt,
updatedAt: this._updatedAt,
};
return {
...base,
...(this._selectedAvatarIndex !== undefined && {
selectedAvatarIndex: this._selectedAvatarIndex,
}),
...(this._errorMessage !== undefined && {
errorMessage: this._errorMessage,
}),
};
}
}

View File

@@ -4,7 +4,7 @@
* Defines the contract for avatar generation request persistence.
*/
import type { AvatarGenerationRequest, AvatarGenerationRequestProps } from '../entities/AvatarGenerationRequest';
import type { AvatarGenerationRequest } from '../entities/AvatarGenerationRequest';
export interface IAvatarGenerationRepository {
/**

View File

@@ -9,4 +9,5 @@ export * from './application/use-cases/SelectAvatarUseCase';
// Domain
export * from './domain/entities/AvatarGenerationRequest';
export * from './domain/repositories/IAvatarGenerationRepository';
export * from './domain/repositories/IAvatarGenerationRepository';
export type { AvatarGenerationRequestProps } from './domain/types/AvatarGenerationRequest';

View File

@@ -64,9 +64,9 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
title: command.title,
body: command.body,
channel: 'in_app',
data: command.data,
actionUrl: command.actionUrl,
status: 'dismissed', // Auto-dismiss since user doesn't want these
...(command.data ? { data: command.data } : {}),
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
});
await this.notificationRepository.create(notification);
@@ -102,11 +102,13 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
title: command.title,
body: command.body,
channel,
urgency: command.urgency,
data: command.data,
actionUrl: command.actionUrl,
actions: command.actions,
requiresResponse: command.requiresResponse,
...(command.urgency ? { urgency: command.urgency } : {}),
...(command.data ? { data: command.data } : {}),
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
...(command.actions ? { actions: command.actions } : {}),
...(command.requiresResponse !== undefined
? { requiresResponse: command.requiresResponse }
: {}),
});
// Save to repository (in_app channel) or attempt delivery (external channels)

View File

@@ -184,12 +184,17 @@ export class Notification implements IEntity<string> {
* Mark that the user has responded to an action_required notification
*/
markAsResponded(actionId?: string): Notification {
const data =
actionId !== undefined
? { ...(this.props.data ?? {}), responseActionId: actionId }
: this.props.data;
return new Notification({
...this.props,
status: 'read',
readAt: this.props.readAt ?? new Date(),
respondedAt: new Date(),
data: actionId ? { ...this.props.data, responseActionId: actionId } : this.props.data,
...(data !== undefined ? { data } : {}),
});
}

View File

@@ -172,26 +172,32 @@ export class NotificationPreference implements IEntity<string> {
* Update quiet hours
*/
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
const validated = start === undefined || end === undefined ? undefined : QuietHours.create(start, end);
const props = this.toJSON();
return new NotificationPreference({
...this.props,
quietHoursStart: validated?.props.startHour,
quietHoursEnd: validated?.props.endHour,
updatedAt: new Date(),
});
if (start === undefined || end === undefined) {
delete props.quietHoursStart;
delete props.quietHoursEnd;
} else {
const validated = QuietHours.create(start, end);
props.quietHoursStart = validated.props.startHour;
props.quietHoursEnd = validated.props.endHour;
}
props.updatedAt = new Date();
return NotificationPreference.create(props);
}
/**
* Toggle digest mode
*/
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
return new NotificationPreference({
...this.props,
digestMode: enabled,
digestFrequencyHours: frequencyHours ?? this.props.digestFrequencyHours,
updatedAt: new Date(),
});
const props = this.toJSON();
props.digestMode = enabled;
if (frequencyHours !== undefined) {
props.digestFrequencyHours = frequencyHours;
}
props.updatedAt = new Date();
return NotificationPreference.create(props);
}
/**

View File

@@ -14,10 +14,10 @@ export interface LeagueScheduleDTO {
raceStartTime: string;
timezoneId: string;
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: Weekday;
intervalWeeks?: number | undefined;
weekdays?: Weekday[] | undefined;
monthlyOrdinal?: 1 | 2 | 3 | 4 | undefined;
monthlyWeekday?: Weekday | undefined;
plannedRounds: number;
}
@@ -54,24 +54,26 @@ export function leagueTimingsToScheduleDTO(
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
if (!dto.seasonStartDate) {
throw new RacingApplicationError('seasonStartDate is required');
throw new BusinessRuleViolationError('seasonStartDate is required');
}
if (!dto.raceStartTime) {
throw new RacingApplicationError('raceStartTime is required');
throw new BusinessRuleViolationError('raceStartTime is required');
}
if (!dto.timezoneId) {
throw new RacingApplicationError('timezoneId is required');
throw new BusinessRuleViolationError('timezoneId is required');
}
if (!dto.recurrenceStrategy) {
throw new RacingApplicationError('recurrenceStrategy is required');
throw new BusinessRuleViolationError('recurrenceStrategy is required');
}
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
throw new RacingApplicationError('plannedRounds must be a positive integer');
throw new BusinessRuleViolationError('plannedRounds must be a positive integer');
}
const startDate = new Date(dto.seasonStartDate);
if (Number.isNaN(startDate.getTime())) {
throw new RacingApplicationError(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
throw new BusinessRuleViolationError(
`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`,
);
}
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
@@ -81,15 +83,17 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
if (dto.recurrenceStrategy === 'weekly') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new RacingApplicationError('weekdays are required for weekly recurrence');
throw new BusinessRuleViolationError('weekdays are required for weekly recurrence');
}
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new RacingApplicationError('weekdays are required for everyNWeeks recurrence');
throw new BusinessRuleViolationError('weekdays are required for everyNWeeks recurrence');
}
if (dto.intervalWeeks == null) {
throw new RacingApplicationError('intervalWeeks is required for everyNWeeks recurrence');
throw new BusinessRuleViolationError(
'intervalWeeks is required for everyNWeeks recurrence',
);
}
recurrence = RecurrenceStrategyFactory.everyNWeeks(
dto.intervalWeeks,
@@ -97,12 +101,14 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
);
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
throw new RacingApplicationError('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
throw new BusinessRuleViolationError(
'monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday',
);
}
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} else {
throw new RacingApplicationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
throw new BusinessRuleViolationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
}
return new SeasonSchedule({

View File

@@ -22,7 +22,9 @@ export type RacingEntityType =
| 'sponsorship'
| 'sponsorshipRequest'
| 'driver'
| 'membership';
| 'membership'
| 'sponsor'
| 'protest';
export interface EntityNotFoundDetails {
entity: RacingEntityType;

View File

@@ -24,13 +24,29 @@ export class EntityMappers {
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
bio: driver.bio ?? '',
joinedAt: driver.joinedAt.toISOString(),
};
}
static toLeagueDTO(league: League | null): LeagueDTO | null {
if (!league) return null;
const socialLinks =
league.socialLinks !== undefined
? {
...(league.socialLinks.discordUrl !== undefined
? { discordUrl: league.socialLinks.discordUrl }
: {}),
...(league.socialLinks.youtubeUrl !== undefined
? { youtubeUrl: league.socialLinks.youtubeUrl }
: {}),
...(league.socialLinks.websiteUrl !== undefined
? { websiteUrl: league.socialLinks.websiteUrl }
: {}),
}
: undefined;
return {
id: league.id,
name: league.name,
@@ -38,35 +54,37 @@ export class EntityMappers {
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
// usedSlots is populated by capacity-aware queries, so leave undefined here
usedSlots: undefined,
...(socialLinks !== undefined ? { socialLinks } : {}),
};
}
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
return leagues.map(league => ({
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots: undefined,
}));
return leagues.map((league) => {
const socialLinks =
league.socialLinks !== undefined
? {
...(league.socialLinks.discordUrl !== undefined
? { discordUrl: league.socialLinks.discordUrl }
: {}),
...(league.socialLinks.youtubeUrl !== undefined
? { youtubeUrl: league.socialLinks.youtubeUrl }
: {}),
...(league.socialLinks.websiteUrl !== undefined
? { websiteUrl: league.socialLinks.websiteUrl }
: {}),
}
: undefined;
return {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
...(socialLinks !== undefined ? { socialLinks } : {}),
};
});
}
static toRaceDTO(race: Race | null): RaceDTO | null {
@@ -76,31 +94,43 @@ export class EntityMappers {
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
...(race.strengthOfField !== undefined
? { strengthOfField: race.strengthOfField }
: {}),
...(race.registeredCount !== undefined
? { registeredCount: race.registeredCount }
: {}),
...(race.maxParticipants !== undefined
? { maxParticipants: race.maxParticipants }
: {}),
};
}
static toRaceDTOs(races: Race[]): RaceDTO[] {
return races.map(race => ({
return races.map((race) => ({
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
trackId: race.trackId ?? '',
car: race.car,
carId: race.carId,
carId: race.carId ?? '',
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
...(race.strengthOfField !== undefined
? { strengthOfField: race.strengthOfField }
: {}),
...(race.registeredCount !== undefined
? { registeredCount: race.registeredCount }
: {}),
...(race.maxParticipants !== undefined
? { maxParticipants: race.maxParticipants }
: {}),
}));
}

View File

@@ -1,4 +1,5 @@
import type { Team } from '../../domain/entities/Team';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamListItemViewModel {
id: string;
@@ -17,6 +18,9 @@ export interface AllTeamsViewModel {
totalCount: number;
}
export interface IAllTeamsPresenter {
present(teams: Team[]): AllTeamsViewModel;
}
export interface AllTeamsResultDTO {
teams: Array<Team & { memberCount: number }>;
}
export interface IAllTeamsPresenter
extends Presenter<AllTeamsResultDTO, AllTeamsViewModel> {}

View File

@@ -1,5 +1,6 @@
import type { Team } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface DriverTeamViewModel {
team: {
@@ -22,10 +23,11 @@ export interface DriverTeamViewModel {
canManage: boolean;
}
export interface IDriverTeamPresenter {
present(
team: Team,
membership: TeamMembership,
driverId: string
): DriverTeamViewModel;
}
export interface DriverTeamResultDTO {
team: Team;
membership: TeamMembership;
driverId: string;
}
export interface IDriverTeamPresenter
extends Presenter<DriverTeamResultDTO, DriverTeamViewModel> {}

View File

@@ -31,4 +31,6 @@ export interface IDriversLeaderboardPresenter {
stats: Record<string, { rating: number; wins: number; podiums: number; totalRaces: number; overallRank: number }>,
avatarUrls: Record<string, string>
): DriversLeaderboardViewModel;
getViewModel(): DriversLeaderboardViewModel;
}

View File

@@ -37,4 +37,5 @@ export interface ILeagueDriverSeasonStatsPresenter {
driverResults: Map<string, Array<{ position: number }>>,
driverRatings: Map<string, { rating: number | null; ratingChange: number | null }>
): LeagueDriverSeasonStatsViewModel;
getViewModel(): LeagueDriverSeasonStatsViewModel;
}

View File

@@ -2,6 +2,7 @@ import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueConfigFormViewModel {
leagueId: string;
@@ -49,6 +50,7 @@ export interface LeagueConfigFormViewModel {
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
requiredVotes?: number;
};
}
@@ -59,6 +61,5 @@ export interface LeagueFullConfigData {
game?: Game;
}
export interface ILeagueFullConfigPresenter {
present(data: LeagueFullConfigData): LeagueConfigFormViewModel;
}
export interface ILeagueFullConfigPresenter
extends Presenter<LeagueFullConfigData, LeagueConfigFormViewModel> {}

View File

@@ -1,4 +1,4 @@
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { ChampionshipConfig } from '../../domain/types/ChampionshipConfig';
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
export interface LeagueScoringChampionshipViewModel {

View File

@@ -1,10 +1,14 @@
import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface LeagueScoringPresetsViewModel {
presets: LeagueScoringPresetDTO[];
totalCount: number;
}
export interface ILeagueScoringPresetsPresenter {
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel;
}
export interface LeagueScoringPresetsResultDTO {
presets: LeagueScoringPresetDTO[];
}
export interface ILeagueScoringPresetsPresenter
extends Presenter<LeagueScoringPresetsResultDTO, LeagueScoringPresetsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { Standing } from '../../domain/entities/Standing';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface StandingItemViewModel {
id: string;
@@ -17,6 +18,9 @@ export interface LeagueStandingsViewModel {
standings: StandingItemViewModel[];
}
export interface ILeagueStandingsPresenter {
present(standings: Standing[]): LeagueStandingsViewModel;
}
export interface LeagueStandingsResultDTO {
standings: Standing[];
}
export interface ILeagueStandingsPresenter
extends Presenter<LeagueStandingsResultDTO, LeagueStandingsViewModel> {}

View File

@@ -1,5 +1,7 @@
import type { Presenter } from '@gridpilot/shared/presentation';
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsUseCase';
export interface IPendingSponsorshipRequestsPresenter {
present(data: GetPendingSponsorshipRequestsResultDTO): void;
}
export type PendingSponsorshipRequestsViewModel = GetPendingSponsorshipRequestsResultDTO;
export interface IPendingSponsorshipRequestsPresenter
extends Presenter<GetPendingSponsorshipRequestsResultDTO, PendingSponsorshipRequestsViewModel> {}

View File

@@ -9,6 +9,7 @@ export interface ProfileOverviewDriverSummaryViewModel {
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface ProfileOverviewStatsViewModel {
@@ -23,6 +24,9 @@ export interface ProfileOverviewStatsViewModel {
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface ProfileOverviewFinishDistributionViewModel {

View File

@@ -1,4 +1,5 @@
import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RacePenaltyViewModel {
id: string;
@@ -22,23 +23,24 @@ export interface RacePenaltiesViewModel {
penalties: RacePenaltyViewModel[];
}
export interface IRacePenaltiesPresenter {
present(
penalties: Array<{
id: string;
raceId: string;
driverId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
status: PenaltyStatus;
issuedAt: Date;
appliedAt?: Date;
notes?: string;
getDescription(): string;
}>,
driverMap: Map<string, string>
): RacePenaltiesViewModel;
}
export interface RacePenaltiesResultDTO {
penalties: Array<{
id: string;
raceId: string;
driverId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
issuedBy: string;
status: PenaltyStatus;
issuedAt: Date;
appliedAt?: Date;
notes?: string;
getDescription(): string;
}>;
driverMap: Map<string, string>;
}
export interface IRacePenaltiesPresenter
extends Presenter<RacePenaltiesResultDTO, RacePenaltiesViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest';
import type { Presenter } from '@gridpilot/shared/presentation/Presenter';
export interface RaceProtestViewModel {
id: string;
@@ -22,22 +23,23 @@ export interface RaceProtestsViewModel {
protests: RaceProtestViewModel[];
}
export interface IRaceProtestsPresenter {
present(
protests: Array<{
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
decisionNotes?: string;
filedAt: Date;
reviewedAt?: Date;
}>,
driverMap: Map<string, string>
): RaceProtestsViewModel;
}
export interface RaceProtestsResultDTO {
protests: Array<{
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
status: ProtestStatus;
reviewedBy?: string;
decisionNotes?: string;
filedAt: Date;
reviewedAt?: Date;
}>;
driverMap: Map<string, string>;
}
export interface IRaceProtestsPresenter
extends Presenter<RaceProtestsResultDTO, RaceProtestsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { TeamJoinRequest } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamJoinRequestViewModel {
requestId: string;
@@ -16,10 +17,11 @@ export interface TeamJoinRequestsViewModel {
totalCount: number;
}
export interface ITeamJoinRequestsPresenter {
present(
requests: TeamJoinRequest[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamJoinRequestsViewModel;
}
export interface TeamJoinRequestsResultDTO {
requests: TeamJoinRequest[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamJoinRequestsPresenter
extends Presenter<TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel> {}

View File

@@ -1,4 +1,5 @@
import type { TeamMembership } from '../../domain/types/TeamMembership';
import type { Presenter } from '@gridpilot/shared/presentation';
export interface TeamMemberViewModel {
driverId: string;
@@ -17,10 +18,11 @@ export interface TeamMembersViewModel {
memberCount: number;
}
export interface ITeamMembersPresenter {
present(
memberships: TeamMembership[],
driverNames: Record<string, string>,
avatarUrls: Record<string, string>
): TeamMembersViewModel;
}
export interface TeamMembersResultDTO {
memberships: TeamMembership[];
driverNames: Record<string, string>;
avatarUrls: Record<string, string>;
}
export interface ITeamMembersPresenter
extends Presenter<TeamMembersResultDTO, TeamMembersViewModel> {}

View File

@@ -1,3 +1,5 @@
import type { Presenter } from '@gridpilot/shared/presentation';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
export interface TeamLeaderboardItemViewModel {
@@ -29,7 +31,10 @@ export interface TeamsLeaderboardViewModel {
topTeams: TeamLeaderboardItemViewModel[];
}
export interface ITeamsLeaderboardPresenter {
present(teams: any[], recruitingCount: number): void;
getViewModel(): TeamsLeaderboardViewModel;
}
export interface TeamsLeaderboardResultDTO {
teams: unknown[];
recruitingCount: number;
}
export interface ITeamsLeaderboardPresenter
extends Presenter<TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel> {}

View File

@@ -56,29 +56,37 @@ export class ApplyForSponsorshipUseCase
}
if (!pricing.acceptingApplications) {
throw new RacingApplicationError('This entity is not currently accepting sponsorship applications');
throw new BusinessRuleViolationError(
'This entity is not currently accepting sponsorship applications',
);
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
throw new RacingApplicationError(`No ${dto.tier} sponsorship slots are available`);
throw new BusinessRuleViolationError(
`No ${dto.tier} sponsorship slots are available`,
);
}
// Check if sponsor already has a pending request for this entity
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
dto.sponsorId,
dto.entityType,
dto.entityId
dto.entityId,
);
if (hasPending) {
throw new RacingApplicationError('You already have a pending sponsorship request for this entity');
throw new BusinessRuleViolationError(
'You already have a pending sponsorship request for this entity',
);
}
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) {
throw new RacingApplicationError(`Offered amount must be at least ${minPrice.format()}`);
throw new BusinessRuleViolationError(
`Offered amount must be at least ${minPrice.format()}`,
);
}
// Create the sponsorship request
@@ -92,7 +100,7 @@ export class ApplyForSponsorshipUseCase
entityId: dto.entityId,
tier: dto.tier,
offeredAmount,
message: dto.message,
...(dto.message !== undefined ? { message: dto.message } : {}),
});
await this.sponsorshipRequestRepo.create(request);

View File

@@ -70,13 +70,13 @@ export class ApplyPenaltyUseCase
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
value: command.value,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
protestId: command.protestId,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
notes: command.notes,
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);

View File

@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { Season } from '../../domain/entities/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
@@ -70,30 +71,28 @@ export class CreateLeagueWithSeasonAndScoringUseCase
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
pointsSystem: (command.scoringPresetId as any) ?? 'custom',
maxDrivers: command.maxDrivers,
// Presets are attached at scoring-config level; league settings use a stable points system id.
pointsSystem: 'custom',
...(command.maxDrivers !== undefined ? { maxDrivers: command.maxDrivers } : {}),
},
});
await this.leagueRepository.create(league);
const seasonId = uuidv4();
const season = {
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
order: 1,
status: 'active' as const,
status: 'active',
startDate: new Date(),
endDate: new Date(),
};
});
// Season is a domain entity; use the repository's create, but shape matches Season.create expectations.
// To keep this use case independent, we rely on repository to persist the plain object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.seasonRepository.create(season as any);
await this.seasonRepository.create(season);
const presetId = command.scoringPresetId ?? 'club-default';
const preset: LeagueScoringPresetDTO | undefined =

View File

@@ -55,8 +55,8 @@ export class FileProtestUseCase {
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
comment: command.comment,
proofVideoUrl: command.proofVideoUrl,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});

View File

@@ -46,9 +46,9 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig;
let game;
let preset;
let scoringConfig: LeagueEnrichedData['scoringConfig'];
let game: LeagueEnrichedData['game'];
let preset: LeagueEnrichedData['preset'];
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
@@ -65,9 +65,9 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
league,
usedDriverSlots,
season: activeSeason,
scoringConfig,
game,
preset,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
...(preset ?? undefined ? { preset } : {}),
});
}

View File

@@ -1,34 +1,43 @@
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';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
} from '../presenters/IAllTeamsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
import type { Team } from '../../domain/entities/Team';
/**
* Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllTeamsUseCase
implements AsyncUseCase<void, void> {
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
public readonly presenter: IAllTeamsPresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> {
presenter.reset();
const teams = await this.teamRepository.findAll();
// Enrich teams with member counts
const enrichedTeams = await Promise.all(
const enrichedTeams: Array<Team & { memberCount: number }> = await Promise.all(
teams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
...team,
memberCount: memberships.length,
memberCount,
};
})
}),
);
this.presenter.present(enrichedTeams as any);
const dto: AllTeamsResultDTO = {
teams: enrichedTeams,
};
presenter.present(dto);
}
}

View File

@@ -1,32 +1,46 @@
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';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '../presenters/IDriverTeamPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetDriverTeamUseCase
implements AsyncUseCase<string, boolean> {
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter>
{
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: IDriverTeamPresenter,
) {}
async execute(driverId: string): Promise<boolean> {
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
async execute(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise<void> {
presenter.reset();
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
return false;
return;
}
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
return false;
return;
}
this.presenter.present(team, membership, driverId);
return true;
const dto: DriverTeamResultDTO = {
team,
membership,
driverId: input.driverId,
};
presenter.present(dto);
}
}

View File

@@ -79,7 +79,9 @@ export class GetEntitySponsorshipPricingUseCase
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
customRequirements: pricing.customRequirements,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
};
if (pricing.mainSlot) {

View File

@@ -2,8 +2,12 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
@@ -11,17 +15,16 @@ import { EntityNotFoundError } from '../errors/RacingApplicationError';
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase
implements AsyncUseCase<{ leagueId: string }, void>
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
public readonly presenter: ILeagueFullConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<void> {
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
@@ -35,23 +38,23 @@ export class GetLeagueFullConfigUseCase
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig;
let game;
if (activeSeason) {
scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (activeSeason.gameId) {
game = await this.gameRepository.findById(activeSeason.gameId);
}
}
let scoringConfig = await (async () => {
if (!activeSeason) return undefined;
return this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
})();
let game = await (async () => {
if (!activeSeason || !activeSeason.gameId) return undefined;
return this.gameRepository.findById(activeSeason.gameId);
})();
const data: LeagueFullConfigData = {
league,
activeSeason,
scoringConfig,
game,
...(scoringConfig ?? undefined ? { scoringConfig } : {}),
...(game ?? undefined ? { game } : {}),
};
this.presenter.present(data);
presenter.reset();
presenter.present(data);
}
}

View File

@@ -33,10 +33,14 @@ export class GetLeagueScoringConfigUseCase
if (!seasons || seasons.length === 0) {
throw new Error(`No seasons found for league ${leagueId}`);
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
throw new Error(`No active season could be determined for league ${leagueId}`);
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
@@ -50,14 +54,14 @@ export class GetLeagueScoringConfigUseCase
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const data: LeagueScoringConfigData = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
scoringPresetId: presetId,
preset,
...(presetId !== undefined ? { scoringPresetId: presetId } : {}),
...(preset !== undefined ? { preset } : {}),
championships: scoringConfig.championships,
};

View File

@@ -1,6 +1,10 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueStandingsPresenter } from '../presenters/ILeagueStandingsPresenter';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type {
ILeagueStandingsPresenter,
LeagueStandingsResultDTO,
LeagueStandingsViewModel,
} from '../presenters/ILeagueStandingsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
@@ -11,14 +15,20 @@ export interface GetLeagueStandingsUseCaseParams {
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase
implements AsyncUseCase<GetLeagueStandingsUseCaseParams, void> {
constructor(
private readonly standingRepository: IStandingRepository,
public readonly presenter: ILeagueStandingsPresenter,
) {}
implements
UseCase<GetLeagueStandingsUseCaseParams, LeagueStandingsResultDTO, LeagueStandingsViewModel, ILeagueStandingsPresenter>
{
constructor(private readonly standingRepository: IStandingRepository) {}
async execute(params: GetLeagueStandingsUseCaseParams): Promise<void> {
async execute(
params: GetLeagueStandingsUseCaseParams,
presenter: ILeagueStandingsPresenter,
): Promise<void> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
this.presenter.present(standings);
const dto: LeagueStandingsResultDTO = {
standings,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -8,7 +8,11 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { IPendingSponsorshipRequestsPresenter } from '../presenters/IPendingSponsorshipRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '../presenters/IPendingSponsorshipRequestsPresenter';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
@@ -37,14 +41,23 @@ export interface GetPendingSponsorshipRequestsResultDTO {
totalCount: number;
}
export class GetPendingSponsorshipRequestsUseCase {
export class GetPendingSponsorshipRequestsUseCase
implements UseCase<
GetPendingSponsorshipRequestsDTO,
GetPendingSponsorshipRequestsResultDTO,
PendingSponsorshipRequestsViewModel,
IPendingSponsorshipRequestsPresenter
> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly presenter: IPendingSponsorshipRequestsPresenter,
) {}
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<void> {
async execute(
dto: GetPendingSponsorshipRequestsDTO,
presenter: IPendingSponsorshipRequestsPresenter,
): Promise<void> {
presenter.reset();
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
@@ -59,12 +72,12 @@ export class GetPendingSponsorshipRequestsUseCase {
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
sponsorLogo: sponsor?.logoUrl,
...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}),
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
message: request.message,
...(request.message !== undefined ? { message: request.message } : {}),
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
@@ -74,7 +87,7 @@ export class GetPendingSponsorshipRequestsUseCase {
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
this.presenter.present({
presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,

View File

@@ -50,7 +50,7 @@ export class GetProfileOverviewUseCase {
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<void> {
async execute(params: GetProfileOverviewParams): Promise<ProfileOverviewViewModel | null> {
const { driverId } = params;
const driver = await this.driverRepository.findById(driverId);
@@ -69,7 +69,7 @@ export class GetProfileOverviewUseCase {
};
this.presenter.present(emptyViewModel);
return;
return emptyViewModel;
}
const [statsAdapter, teams, friends] = await Promise.all([
@@ -95,6 +95,7 @@ export class GetProfileOverviewUseCase {
};
this.presenter.present(viewModel);
return viewModel;
}
private buildDriverSummary(
@@ -103,6 +104,7 @@ export class GetProfileOverviewUseCase {
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
@@ -110,13 +112,15 @@ export class GetProfileOverviewUseCase {
country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId ?? null,
joinedAt: driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
joinedAt:
driver.joinedAt instanceof Date
? driver.joinedAt.toISOString()
: new Date(driver.joinedAt).toISOString(),
rating: stats?.rating ?? null,
globalRank: stats?.overallRank ?? fallbackRank,
consistency: stats?.consistency ?? null,
bio: driver.bio ?? null,
totalDrivers,
};
}
@@ -161,6 +165,9 @@ export class GetProfileOverviewUseCase {
winRate,
podiumRate,
percentile: stats.percentile,
rating: stats.rating,
consistency: stats.consistency,
overallRank: stats.overallRank,
};
}
@@ -417,8 +424,10 @@ export class GetProfileOverviewUseCase {
'Flexible schedule',
];
const socialHandles = socialOptions[hash % socialOptions.length];
const achievementsSource = achievementSets[hash % achievementSets.length];
const socialHandles =
socialOptions[hash % socialOptions.length] ?? [];
const achievementsSource =
achievementSets[hash % achievementSets.length] ?? [];
return {
socialHandles,
@@ -430,11 +439,11 @@ export class GetProfileOverviewUseCase {
rarity: achievement.rarity,
earnedAt: achievement.earnedAt.toISOString(),
})),
racingStyle: styles[hash % styles.length],
favoriteTrack: tracks[hash % tracks.length],
favoriteCar: cars[hash % cars.length],
timezone: timezones[hash % timezones.length],
availableHours: hours[hash % hours.length],
racingStyle: styles[hash % styles.length] ?? 'Consistent Pacer',
favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track',
favoriteCar: cars[hash % cars.length] ?? 'Unknown Car',
timezone: timezones[hash % timezones.length] ?? 'UTC',
availableHours: hours[hash % hours.length] ?? 'Flexible schedule',
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};

View File

@@ -7,36 +7,51 @@
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRacePenaltiesPresenter } from '../presenters/IRacePenaltiesPresenter';
import type {
IRacePenaltiesPresenter,
RacePenaltiesResultDTO,
RacePenaltiesViewModel,
} from '../presenters/IRacePenaltiesPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRacePenaltiesUseCase {
export interface GetRacePenaltiesInput {
raceId: string;
}
export class GetRacePenaltiesUseCase
implements
UseCase<GetRacePenaltiesInput, RacePenaltiesResultDTO, RacePenaltiesViewModel, IRacePenaltiesPresenter>
{
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRacePenaltiesPresenter,
) {}
async execute(raceId: string): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(raceId);
// Load all driver details in parallel
async execute(input: GetRacePenaltiesInput, presenter: IRacePenaltiesPresenter): Promise<void> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
penalties.forEach(penalty => {
penalties.forEach((penalty) => {
driverIds.add(penalty.driverId);
driverIds.add(penalty.issuedBy);
});
const drivers = await Promise.all(
Array.from(driverIds).map(id => this.driverRepository.findById(id))
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach(driver => {
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
this.presenter.present(penalties, driverMap);
presenter.reset();
const dto: RacePenaltiesResultDTO = {
penalties,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -7,21 +7,31 @@
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceProtestsPresenter } from '../presenters/IRaceProtestsPresenter';
import type {
IRaceProtestsPresenter,
RaceProtestsResultDTO,
RaceProtestsViewModel,
} from '../presenters/IRaceProtestsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
export class GetRaceProtestsUseCase {
export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase
implements
UseCase<GetRaceProtestsInput, RaceProtestsResultDTO, RaceProtestsViewModel, IRaceProtestsPresenter>
{
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
public readonly presenter: IRaceProtestsPresenter,
) {}
async execute(raceId: string): Promise<void> {
const protests = await this.protestRepository.findByRaceId(raceId);
// Load all driver details in parallel
async execute(input: GetRaceProtestsInput, presenter: IRaceProtestsPresenter): Promise<void> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
protests.forEach(protest => {
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
@@ -30,16 +40,21 @@ export class GetRaceProtestsUseCase {
});
const drivers = await Promise.all(
Array.from(driverIds).map(id => this.driverRepository.findById(id))
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const driverMap = new Map<string, string>();
drivers.forEach(driver => {
drivers.forEach((driver) => {
if (driver) {
driverMap.set(driver.id, driver.name);
}
});
this.presenter.present(protests, driverMap);
presenter.reset();
const dto: RaceProtestsResultDTO = {
protests,
driverMap,
};
presenter.present(dto);
}
}

View File

@@ -69,7 +69,7 @@ function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewM
return penalties.map((p) => ({
driverId: p.driverId,
type: p.type,
value: p.value,
...(p.value !== undefined ? { value: p.value } : {}),
}));
}
@@ -96,7 +96,6 @@ export class GetRaceResultsDetailUseCase {
drivers: [],
penalties: [],
pointsSystem: {},
fastestLapTime: undefined,
currentDriverId: driverId,
error: 'Race not found',
};
@@ -117,7 +116,7 @@ export class GetRaceResultsDetailUseCase {
const pointsSystem = buildPointsSystem(league as League | null);
const fastestLapTime = getFastestLapTime(results);
const penaltySummary = mapPenaltySummary(penalties);
const viewModel: RaceResultsDetailViewModel = {
race: {
id: race.id,
@@ -136,7 +135,7 @@ export class GetRaceResultsDetailUseCase {
drivers,
penalties: penaltySummary,
pointsSystem,
fastestLapTime,
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
currentDriverId: effectiveCurrentDriverId,
};

View File

@@ -121,8 +121,8 @@ export class GetSponsorSponsorshipsUseCase {
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
seasonStartDate: season.startDate,
seasonEndDate: season.endDate,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
@@ -144,7 +144,7 @@ export class GetSponsorSponsorshipsUseCase {
impressions,
},
createdAt: sponsorship.createdAt,
activatedAt: sponsorship.activatedAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
});
}

View File

@@ -1,26 +1,37 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamJoinRequestsPresenter } from '../presenters/ITeamJoinRequestsPresenter';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '../presenters/ITeamJoinRequestsPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving team join requests.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamJoinRequestsUseCase {
export class GetTeamJoinRequestsUseCase
implements UseCase<{ teamId: string }, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, ITeamJoinRequestsPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamJoinRequestsPresenter,
) {}
async execute(teamId: string): Promise<void> {
const requests = await this.membershipRepository.getJoinRequests(teamId);
async execute(input: { teamId: string }, presenter: ITeamJoinRequestsPresenter): Promise<void> {
presenter.reset();
const requests = await this.membershipRepository.getJoinRequests(input.teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const request of requests) {
const driver = await this.driverRepository.findById(request.driverId);
if (driver) {
@@ -28,7 +39,13 @@ export class GetTeamJoinRequestsUseCase {
}
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
}
this.presenter.present(requests, driverNames, avatarUrls);
const dto: TeamJoinRequestsResultDTO = {
requests,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -1,26 +1,37 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ITeamMembersPresenter } from '../presenters/ITeamMembersPresenter';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '../presenters/ITeamMembersPresenter';
import type { UseCase } from '@gridpilot/shared/application';
/**
* Use Case for retrieving team members.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetTeamMembersUseCase {
export class GetTeamMembersUseCase
implements UseCase<{ teamId: string }, TeamMembersResultDTO, TeamMembersViewModel, ITeamMembersPresenter>
{
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: ITeamMembersPresenter,
) {}
async execute(teamId: string): Promise<void> {
const memberships = await this.membershipRepository.getTeamMembers(teamId);
async execute(input: { teamId: string }, presenter: ITeamMembersPresenter): Promise<void> {
presenter.reset();
const memberships = await this.membershipRepository.getTeamMembers(input.teamId);
const driverNames: Record<string, string> = {};
const avatarUrls: Record<string, string> = {};
for (const membership of memberships) {
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
@@ -28,7 +39,13 @@ export class GetTeamMembersUseCase {
}
avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId);
}
this.presenter.present(memberships, driverNames, avatarUrls);
const dto: TeamMembersResultDTO = {
memberships,
driverNames,
avatarUrls,
};
presenter.present(dto);
}
}

View File

@@ -1,8 +1,13 @@
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ITeamsLeaderboardPresenter } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import type {
ITeamsLeaderboardPresenter,
TeamsLeaderboardResultDTO,
TeamsLeaderboardViewModel,
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
interface DriverStatsAdapter {
rating: number | null;
@@ -16,22 +21,22 @@ interface DriverStatsAdapter {
* Plain constructor-injected dependencies (no decorators) to keep the
* application layer framework-agnostic and compatible with test tooling.
*/
export class GetTeamsLeaderboardUseCase {
export class GetTeamsLeaderboardUseCase
implements UseCase<void, TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel, ITeamsLeaderboardPresenter> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
public readonly presenter: ITeamsLeaderboardPresenter,
) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: ITeamsLeaderboardPresenter): Promise<void> {
const allTeams = await this.teamRepository.findAll();
const teams: any[] = [];
await Promise.all(
allTeams.map(async (team) => {
const memberships = await this.teamMembershipRepository.findByTeamId(team.id);
const memberships = await this.teamMembershipRepository.getTeamMembers(team.id);
const memberCount = memberships.length;
let ratingSum = 0;
@@ -66,15 +71,18 @@ export class GetTeamsLeaderboardUseCase {
isRecruiting: true,
createdAt: new Date(),
description: team.description,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
});
})
);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
this.presenter.present(teams, recruitingCount);
const result: TeamsLeaderboardResultDTO = {
teams,
recruitingCount,
};
presenter.reset();
presenter.present(result);
}
}

View File

@@ -1,18 +1,28 @@
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { ILeagueScoringPresetsPresenter } from '../presenters/ILeagueScoringPresetsPresenter';
import type {
ILeagueScoringPresetsPresenter,
LeagueScoringPresetsResultDTO,
LeagueScoringPresetsViewModel,
} from '../presenters/ILeagueScoringPresetsPresenter';
import type { UseCase } from '@gridpilot/shared/application/UseCase';
/**
* Use Case for listing league scoring presets.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class ListLeagueScoringPresetsUseCase {
constructor(
private readonly presetProvider: LeagueScoringPresetProvider,
public readonly presenter: ILeagueScoringPresetsPresenter,
) {}
export class ListLeagueScoringPresetsUseCase
implements UseCase<void, LeagueScoringPresetsResultDTO, LeagueScoringPresetsViewModel, ILeagueScoringPresetsPresenter>
{
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
async execute(): Promise<void> {
async execute(_input: void, presenter: ILeagueScoringPresetsPresenter): Promise<void> {
const presets = await this.presetProvider.listPresets();
this.presenter.present(presets);
const dto: LeagueScoringPresetsResultDTO = {
presets,
};
presenter.reset();
presenter.present(dto);
}
}

View File

@@ -38,14 +38,16 @@ export class RejectSponsorshipRequestUseCase {
// Reject the request
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
await this.sponsorshipRequestRepo.update(rejectedRequest);
// TODO: In a real implementation, notify the sponsor
return {
requestId: rejectedRequest.id,
status: 'rejected',
rejectedAt: rejectedRequest.respondedAt!,
reason: rejectedRequest.rejectionReason,
...(rejectedRequest.rejectionReason !== undefined
? { reason: rejectedRequest.rejectionReason }
: {}),
};
}
}

View File

@@ -24,8 +24,8 @@ export class UpdateDriverProfileUseCase {
}
const updated = existing.update({
bio: bio ?? existing.bio,
country: country ?? existing.country,
...(bio !== undefined ? { bio } : {}),
...(country !== undefined ? { country } : {}),
});
const persisted = await this.driverRepository.update(updated);

View File

@@ -76,9 +76,9 @@ export class Car implements IEntity<string> {
carClass: props.carClass ?? 'gt',
license: props.license ?? 'D',
year: props.year ?? new Date().getFullYear(),
horsepower: props.horsepower,
weight: props.weight,
imageUrl: props.imageUrl,
...(props.horsepower !== undefined ? { horsepower: props.horsepower } : {}),
...(props.weight !== undefined ? { weight: props.weight } : {}),
...(props.imageUrl !== undefined ? { imageUrl: props.imageUrl } : {}),
gameId: props.gameId,
});
}

View File

@@ -46,7 +46,11 @@ export class Driver implements IEntity<string> {
this.validate(props);
return new Driver({
...props,
id: props.id,
iracingId: props.iracingId,
name: props.name,
country: props.country,
...(props.bio !== undefined ? { bio: props.bio } : {}),
joinedAt: props.joinedAt ?? new Date(),
});
}

View File

@@ -1,13 +1,11 @@
/**
* Domain Entity: DriverLivery
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
*
* Represents a driver's custom livery for a specific car.
* Includes user-placed decals and league-specific overrides.
*/
import { RacingDomainValidationError, RacingDomainInvariantError, RacingDomainError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { LiveryDecal } from '../value-objects/LiveryDecal';

View File

@@ -1,13 +1,11 @@
/**
* Domain Entity: LiveryTemplate
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
*
* Represents an admin-defined livery template for a specific car.
* Contains base image and sponsor decal placements.
*/
import { RacingDomainValidationError, RacingDomainInvariantError, RacingDomainError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { LiveryDecal } from '../value-objects/LiveryDecal';

View File

@@ -77,14 +77,14 @@ export class Race implements IEntity<string> {
leagueId: props.leagueId,
scheduledAt: props.scheduledAt,
track: props.track,
trackId: props.trackId,
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
car: props.car,
carId: props.carId,
...(props.carId !== undefined ? { carId: props.carId } : {}),
sessionType: props.sessionType ?? 'race',
status: props.status ?? 'scheduled',
strengthOfField: props.strengthOfField,
registeredCount: props.registeredCount,
maxParticipants: props.maxParticipants,
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
});
}

View File

@@ -70,11 +70,11 @@ export class Season implements IEntity<string> {
leagueId: props.leagueId,
gameId: props.gameId,
name: props.name,
year: props.year,
order: props.order,
...(props.year !== undefined ? { year: props.year } : {}),
...(props.order !== undefined ? { order: props.order } : {}),
status,
startDate: props.startDate,
endDate: props.endDate,
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
});
}

View File

@@ -48,16 +48,22 @@ export class SeasonSponsorship implements IEntity<string> {
this.description = props.description;
}
static create(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'> & {
static create(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'> & {
createdAt?: Date;
status?: SponsorshipStatus;
}): SeasonSponsorship {
this.validate(props);
return new SeasonSponsorship({
...props,
createdAt: props.createdAt ?? new Date(),
id: props.id,
seasonId: props.seasonId,
sponsorId: props.sponsorId,
tier: props.tier,
pricing: props.pricing,
status: props.status ?? 'pending',
createdAt: props.createdAt ?? new Date(),
...(props.activatedAt !== undefined ? { activatedAt: props.activatedAt } : {}),
...(props.description !== undefined ? { description: props.description } : {}),
});
}

View File

@@ -5,7 +5,7 @@
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Standing implements IEntity<string> {

View File

@@ -1,7 +1,7 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';

View File

@@ -1,4 +1,4 @@
import type { Weekday } from './Weekday';
import type { Weekday } from '../types/Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface MonthlyRecurrencePatternProps {

View File

@@ -36,12 +36,59 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
this.customRequirements = props.customRequirements;
}
get props(): SponsorshipPricingProps {
return {
mainSlot: this.mainSlot,
secondarySlots: this.secondarySlots,
acceptingApplications: this.acceptingApplications,
customRequirements: this.customRequirements,
};
}
equals(other: IValueObject<SponsorshipPricingProps>): boolean {
const a = this.props;
const b = other.props;
const mainEqual =
(a.mainSlot === undefined && b.mainSlot === undefined) ||
(a.mainSlot !== undefined &&
b.mainSlot !== undefined &&
a.mainSlot.tier === b.mainSlot.tier &&
a.mainSlot.price.amount === b.mainSlot.price.amount &&
a.mainSlot.price.currency === b.mainSlot.price.currency &&
a.mainSlot.available === b.mainSlot.available &&
a.mainSlot.maxSlots === b.mainSlot.maxSlots &&
a.mainSlot.benefits.length === b.mainSlot.benefits.length &&
a.mainSlot.benefits.every((val, idx) => val === b.mainSlot!.benefits[idx]));
const secondaryEqual =
(a.secondarySlots === undefined && b.secondarySlots === undefined) ||
(a.secondarySlots !== undefined &&
b.secondarySlots !== undefined &&
a.secondarySlots.tier === b.secondarySlots.tier &&
a.secondarySlots.price.amount === b.secondarySlots.price.amount &&
a.secondarySlots.price.currency === b.secondarySlots.price.currency &&
a.secondarySlots.available === b.secondarySlots.available &&
a.secondarySlots.maxSlots === b.secondarySlots.maxSlots &&
a.secondarySlots.benefits.length === b.secondarySlots.benefits.length &&
a.secondarySlots.benefits.every(
(val, idx) => val === b.secondarySlots!.benefits[idx],
));
return (
mainEqual &&
secondaryEqual &&
a.acceptingApplications === b.acceptingApplications &&
a.customRequirements === b.customRequirements
);
}
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: props.mainSlot,
secondarySlots: props.secondarySlots,
...(props.mainSlot !== undefined ? { mainSlot: props.mainSlot } : {}),
...(props.secondarySlots !== undefined ? { secondarySlots: props.secondarySlots } : {}),
acceptingApplications: props.acceptingApplications ?? true,
customRequirements: props.customRequirements,
...(props.customRequirements !== undefined ? { customRequirements: props.customRequirements } : {}),
});
}

View File

@@ -8,11 +8,13 @@
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
type RaceRegistrationSeed = Pick<RaceRegistration, 'raceId' | 'driverId' | 'registeredAt'>;
export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrationsByRace: Map<string, Set<string>>;
private registrationsByDriver: Map<string, Set<string>>;
constructor(seedRegistrations?: RaceRegistration[]) {
constructor(seedRegistrations?: RaceRegistrationSeed[]) {
this.registrationsByRace = new Map();
this.registrationsByDriver = new Map();

View File

@@ -52,6 +52,15 @@ export class InMemorySeasonSponsorshipRepository implements ISeasonSponsorshipRe
return this.sponsorships.has(id);
}
/**
* Seed initial data
*/
seed(sponsorships: SeasonSponsorship[]): void {
for (const sponsorship of sponsorships) {
this.sponsorships.set(sponsorship.id, sponsorship);
}
}
// Test helper
clear(): void {
this.sponsorships.clear();

View File

@@ -51,6 +51,15 @@ export class InMemorySponsorRepository implements ISponsorRepository {
return this.sponsors.has(id);
}
/**
* Seed initial data
*/
seed(sponsors: Sponsor[]): void {
for (const sponsor of sponsors) {
this.sponsors.set(sponsor.id, sponsor);
}
}
// Test helper
clear(): void {
this.sponsors.clear();

View File

@@ -0,0 +1,3 @@
export interface AsyncUseCase<Input, Output> {
execute(input: Input): Promise<Output>;
}

View File

@@ -1,17 +1,5 @@
import { Result } from '../result/Result';
import type { Presenter } from '../presentation';
export interface IUseCase<Input, Output> {
execute(input: Input): Output;
}
export interface AsyncUseCase<Input, Output> {
execute(input: Input): Promise<Output>;
}
export interface ResultUseCase<Input, Output, Error = unknown> {
execute(input: Input): Result<Output, Error>;
}
export interface AsyncResultUseCase<Input, Output, Error = unknown> {
execute(input: Input): Promise<Result<Output, Error>>;
export interface UseCase<Input, OutputDTO, ViewModel, P extends Presenter<OutputDTO, ViewModel>> {
execute(input: Input, presenter: P): Promise<void> | void;
}

View File

@@ -1,2 +1,3 @@
export * from './UseCase';
export * from './AsyncUseCase';
export * from './Service';

View File

@@ -1,257 +0,0 @@
# Value Object Candidates Audit
This document lists domain concepts currently modeled as primitives or simple types that should be refactored into explicit value objects implementing `IValueObject<Props>`.
Priority levels:
- **High**: Cross-cutting identifiers, URLs, or settings with clear invariants and repeated usage.
- **Medium**: Important within a single bounded context but less cross-cutting.
- **Low**: Niche or rarely used concepts.
---
## Analytics
### Analytics/PageView
- **Concept**: `PageViewId` ✅ Implemented
- **Implementation**: [`PageViewId`](packages/analytics/domain/value-objects/PageViewId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:14), [`PageViewId.test`](packages/analytics/domain/value-objects/PageViewId.test.ts)
- **Notes**: Page view identifiers are now modeled as a VO and used internally by the `PageView` entity while repositories and use cases continue to work with primitive string IDs where appropriate.
- **Priority**: High
- **Concept**: `AnalyticsEntityId` (for analytics) ✅ Implemented
- **Implementation**: [`AnalyticsEntityId`](packages/analytics/domain/value-objects/AnalyticsEntityId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:16), [`AnalyticsSnapshot`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:16), [`EngagementEvent`](packages/analytics/domain/entities/EngagementEvent.ts:15), [`AnalyticsEntityId.test`](packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts)
- **Notes**: Entity IDs within the analytics bounded context are now modeled as a VO and used internally in snapshots, engagement events, and page views; external DTOs still expose primitive strings.
- **Priority**: High
- **Concept**: `AnalyticsSessionId` ✅ Implemented
- **Implementation**: [`AnalyticsSessionId`](packages/analytics/domain/value-objects/AnalyticsSessionId.ts), [`PageView`](packages/analytics/domain/entities/PageView.ts:18), [`EngagementEvent`](packages/analytics/domain/entities/EngagementEvent.ts:22), [`AnalyticsSessionId.test`](packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts)
- **Notes**: Session identifiers are now encapsulated in a VO and used internally across analytics entities while preserving primitive session IDs at the boundaries.
- **Priority**: High
- **Concept**: `ReferrerUrl`
- **Location**: [`PageView.referrer`](packages/analytics/domain/entities/PageView.ts:18), [`PageViewProps.referrer`](packages/analytics/domain/types/PageView.ts:19)
- **Why VO**: External URL with semantics around internal vs external (`isExternalReferral` method). Currently string with no URL parsing or normalization.
- **Priority**: Medium
- **Concept**: `CountryCode`
- **Location**: [`PageView.country`](packages/analytics/domain/entities/PageView.ts:20), [`PageViewProps.country`](packages/analytics/domain/types/PageView.ts:21)
- **Why VO**: ISO country codes or similar; currently unvalidated string. Could enforce standardized codes.
- **Priority**: Medium
- **Concept**: `SnapshotId`
- **Location**: [`AnalyticsSnapshot.id`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:16), [`AnalyticsSnapshotProps.id`](packages/analytics/domain/types/AnalyticsSnapshot.ts:27)
- **Why VO**: Identity for time-bucketed analytics snapshots; currently primitive string with simple validation.
- **Priority**: Medium
- **Concept**: `SnapshotPeriod` (as VO vs string union)
- **Location**: [`SnapshotPeriod`](packages/analytics/domain/types/AnalyticsSnapshot.ts:8), [`AnalyticsSnapshot.period`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:20)
- **Why VO**: Has semantics used in [`getPeriodLabel`](packages/analytics/domain/entities/AnalyticsSnapshot.ts:130); could encapsulate formatting logic and date range constraints. Currently a union type only.
- **Priority**: Low (enum-like, acceptable as-is for now)
### Analytics/EngagementEvent
- **Concept**: `EngagementEventId`
- **Location**: [`EngagementEvent.id`](packages/analytics/domain/entities/EngagementEvent.ts:15), [`EngagementEventProps.id`](packages/analytics/domain/types/EngagementEvent.ts:28)
- **Why VO**: Unique ID for engagement events; only non-empty validation today. Could unify ID semantics with other analytics IDs.
- **Priority**: Medium
- **Concept**: `ActorId` (analytics)
- **Location**: [`EngagementEvent.actorId`](packages/analytics/domain/entities/EngagementEvent.ts:20), [`EngagementEventProps.actorId`](packages/analytics/domain/types/EngagementEvent.ts:32)
- **Why VO**: Identifies the actor (anonymous / driver / sponsor) with a type discriminator; could be a specific `ActorId` VO constrained by `actorType`.
- **Priority**: Low (usage seems optional and less central)
---
## Notifications
### Notification Entity
- **Concept**: `NotificationId` ✅ Implemented
- **Implementation**: [`NotificationId`](packages/notifications/domain/value-objects/NotificationId.ts), [`Notification`](packages/notifications/domain/entities/Notification.ts:89), [`NotificationId.test`](packages/notifications/domain/value-objects/NotificationId.test.ts), [`SendNotificationUseCase`](packages/notifications/application/use-cases/SendNotificationUseCase.ts:46)
- **Notes**: Notification aggregate IDs are now modeled as a VO and used internally by the `Notification` entity; repositories and use cases still operate with primitive string IDs via entity factories and serialization.
- **Priority**: High
- **Concept**: `RecipientId` (NotificationRecipientId)
- **Location**: [`NotificationProps.recipientId`](packages/notifications/domain/entities/Notification.ts:59), [`Notification.recipientId`](packages/notifications/domain/entities/Notification.ts:115)
- **Why VO**: Identity of the driver who receives notifications; likely aligns with identity/user IDs and is important for routing.
- **Priority**: High
- **Concept**: `ActionUrl`
- **Location**: [`NotificationProps.actionUrl`](packages/notifications/domain/entities/Notification.ts:75), [`Notification.actionUrl`](packages/notifications/domain/entities/Notification.ts:123)
- **Why VO**: URL used for click-through actions in notifications; should be validated/normalized and may have internal vs external semantics.
- **Priority**: High
- **Concept**: `NotificationActionId`
- **Location**: [`NotificationAction.actionId`](packages/notifications/domain/entities/Notification.ts:53), [`Notification.markAsResponded`](packages/notifications/domain/entities/Notification.ts:182)
- **Why VO**: Identifies action button behavior; currently raw string used to record `responseActionId` in `data`.
- **Priority**: Low
### NotificationPreference Entity
- **Concept**: `NotificationPreferenceId`
- **Location**: [`NotificationPreferenceProps.id`](packages/notifications/domain/entities/NotificationPreference.ts:25), [`NotificationPreference.id`](packages/notifications/domain/entities/NotificationPreference.ts:80)
- **Why VO**: Aggregate ID; currently plain string tied to driver ID; could be constrained to match a `DriverId` or similar.
- **Priority**: Medium
- **Concept**: `PreferenceOwnerId` (driverId)
- **Location**: [`NotificationPreferenceProps.driverId`](packages/notifications/domain/entities/NotificationPreference.ts:28), [`NotificationPreference.driverId`](packages/notifications/domain/entities/NotificationPreference.ts:81)
- **Why VO**: Identifies the driver whose preferences these are; should align with identity/racing driver IDs.
- **Priority**: High
- **Concept**: `QuietHours`
- **Location**: [`NotificationPreferenceProps.quietHoursStart`](packages/notifications/domain/entities/NotificationPreference.ts:38), [`NotificationPreferenceProps.quietHoursEnd`](packages/notifications/domain/entities/NotificationPreference.ts:40), [`NotificationPreference.isInQuietHours`](packages/notifications/domain/entities/NotificationPreference.ts:125)
- **Why VO**: Encapsulates a time window invariant (023, wrap-around support, comparison with current hour); currently implemented as two numbers plus logic in the entity. Ideal VO candidate.
- **Priority**: High
- **Concept**: `DigestFrequency`
- **Location**: [`NotificationPreferenceProps.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:37), [`NotificationPreference.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:87)
- **Why VO**: Represents cadence for digest emails in hours; could enforce positive ranges and provide helper methods.
- **Priority**: Medium
---
## Media
### AvatarGenerationRequest
- **Concept**: `AvatarGenerationRequestId`
- **Location**: [`AvatarGenerationRequest.id`](packages/media/domain/entities/AvatarGenerationRequest.ts:15), [`AvatarGenerationRequestProps.id`](packages/media/domain/types/AvatarGenerationRequest.ts:33)
- **Why VO**: Aggregate ID for avatar generation request lifecycle; currently raw string with only non-empty checks.
- **Priority**: Medium
- **Concept**: `AvatarOwnerId` (userId)
- **Location**: [`AvatarGenerationRequest.userId`](packages/media/domain/entities/AvatarGenerationRequest.ts:17), [`AvatarGenerationRequestProps.userId`](packages/media/domain/types/AvatarGenerationRequest.ts:34)
- **Why VO**: Identity reference to user; could be tied to `UserId` VO or a dedicated `AvatarOwnerId`.
- **Priority**: Medium
- **Concept**: `FacePhotoUrl`
- **Location**: [`AvatarGenerationRequest.facePhotoUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:18), [`AvatarGenerationRequestProps.facePhotoUrl`](packages/media/domain/types/AvatarGenerationRequest.ts:35)
- **Why VO**: External URL to user-submitted media; should be validated, normalized, and potentially constrained to HTTPS or whitelisted hosts.
- **Priority**: High
- **Concept**: `GeneratedAvatarUrl`
- **Location**: [`AvatarGenerationRequest._generatedAvatarUrls`](packages/media/domain/entities/AvatarGenerationRequest.ts:22), [`AvatarGenerationRequestProps.generatedAvatarUrls`](packages/media/domain/types/AvatarGenerationRequest.ts:39), [`AvatarGenerationRequest.selectedAvatarUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:86)
- **Why VO**: Generated asset URLs with invariant that at least one must be present when completed; currently raw strings in an array.
- **Priority**: High
---
## Identity
### SponsorAccount
- **Concept**: `SponsorAccountId`
- **Location**: [`SponsorAccountProps.id`](packages/identity/domain/entities/SponsorAccount.ts:12), [`SponsorAccount.getId`](packages/identity/domain/entities/SponsorAccount.ts:73)
- **Status**: Already a VO (`UserId`) no change needed.
- **Priority**: N/A
- **Concept**: `SponsorId` (link to racing domain)
- **Location**: [`SponsorAccountProps.sponsorId`](packages/identity/domain/entities/SponsorAccount.ts:14), [`SponsorAccount.getSponsorId`](packages/identity/domain/entities/SponsorAccount.ts:77)
- **Why VO**: Cross-bounded-context reference into racing `Sponsor` entity; currently a primitive string with only non-empty validation.
- **Priority**: High
- **Concept**: `SponsorAccountEmail`
- **Location**: [`SponsorAccountProps.email`](packages/identity/domain/entities/SponsorAccount.ts:15), [`SponsorAccount.create` email validation](packages/identity/domain/entities/SponsorAccount.ts:60)
- **Status**: Validation uses [`EmailAddress` VO utilities](packages/identity/domain/value-objects/EmailAddress.ts:15) but the entity still stores `email: string`.
- **Why VO**: Entity should likely store `EmailAddress` instead of a plain string to guarantee invariants wherever it is used.
- **Priority**: High
- **Concept**: `CompanyName`
- **Location**: [`SponsorAccountProps.companyName`](packages/identity/domain/entities/SponsorAccount.ts:17), [`SponsorAccount.getCompanyName`](packages/identity/domain/entities/SponsorAccount.ts:89)
- **Why VO**: Represents sponsor company name with potential invariants (length, prohibited characters). Currently only checked for non-empty.
- **Priority**: Low
---
## Racing
### League Entity
- **Concept**: `LeagueId`
- **Location**: [`League.id`](packages/racing/domain/entities/League.ts:83), `validate` ID check in [`League.validate`](packages/racing/domain/entities/League.ts:157)
- **Why VO**: Aggregate root ID; central to many references (races, teams, sponsorships). Currently primitive string with non-empty validation only.
- **Priority**: High
- **Concept**: `LeagueOwnerId`
- **Location**: [`League.ownerId`](packages/racing/domain/entities/League.ts:87), validation in [`League.validate`](packages/racing/domain/entities/League.ts:179)
- **Why VO**: Identity of league owner; likely maps to a `UserId` or `DriverId` concept; should not remain a free-form string.
- **Priority**: High
- **Concept**: `LeagueSocialLinkUrl` (`DiscordUrl`, `YoutubeUrl`, `WebsiteUrl`)
- **Location**: [`LeagueSocialLinks.discordUrl`](packages/racing/domain/entities/League.ts:77), [`LeagueSocialLinks.youtubeUrl`](packages/racing/domain/entities/League.ts:79), [`LeagueSocialLinks.websiteUrl`](packages/racing/domain/entities/League.ts:80)
- **Why VO**: External URLs across multiple channels; should be validated and normalized; repeated semantics across UI and domain.
- **Priority**: High
### Track Entity
- **Concept**: `TrackId`
- **Location**: [`Track.id`](packages/racing/domain/entities/Track.ts:14), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:92)
- **Why VO**: Aggregate root ID for tracks; referenced from races and schedules; currently primitive string.
- **Priority**: High
- **Concept**: `TrackCountryCode`
- **Location**: [`Track.country`](packages/racing/domain/entities/Track.ts:18), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:100)
- **Why VO**: Represent country using standard codes; currently a free-form string.
- **Priority**: Medium
- **Concept**: `TrackImageUrl`
- **Location**: [`Track.imageUrl`](packages/racing/domain/entities/Track.ts:23)
- **Why VO**: Image asset URL; should be constrained and validated similarly to other URL concepts.
- **Priority**: High
- **Concept**: `GameId`
- **Location**: [`Track.gameId`](packages/racing/domain/entities/Track.ts:24), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:112)
- **Why VO**: Identifier for simulation/game platform; currently string with non-empty validation; may benefit from VO if multiple entities use it.
- **Priority**: Medium
### Race Entity
- **Concept**: `RaceId`
- **Location**: [`Race.id`](packages/racing/domain/entities/Race.ts:14), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:101)
- **Why VO**: Aggregate ID for races; central to many operations and references.
- **Priority**: High
- **Concept**: `RaceLeagueId`
- **Location**: [`Race.leagueId`](packages/racing/domain/entities/Race.ts:16), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:105)
- **Why VO**: Foreign key into `League`; should be modeled as `LeagueId` VO rather than raw string.
- **Priority**: High
- **Concept**: `RaceTrackId` / `RaceCarId`
- **Location**: [`Race.trackId`](packages/racing/domain/entities/Race.ts:19), [`Race.carId`](packages/racing/domain/entities/Race.ts:21)
- **Why VO**: Optional references to track and car entities; currently strings; could be typed IDs aligned with `TrackId` and car ID concepts.
- **Priority**: Medium
- **Concept**: `RaceName` / `TrackName` / `CarName`
- **Location**: [`Race.track`](packages/racing/domain/entities/Race.ts:18), [`Race.car`](packages/racing/domain/entities/Race.ts:20)
- **Why VO**: Displayable names with potential formatting rules; today treated as raw strings, which is acceptable for now.
- **Priority**: Low
### Team Entity
- **Concept**: `TeamId`
- **Location**: [`Team.id`](packages/racing/domain/entities/Team.ts:12), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:108)
- **Why VO**: Aggregate ID; referenced from standings, registrations, etc. Currently primitive.
- **Priority**: High
- **Concept**: `TeamOwnerId`
- **Location**: [`Team.ownerId`](packages/racing/domain/entities/Team.ts:17), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:120)
- **Why VO**: Identity of team owner; should map to `UserId` or `DriverId`, currently a simple string.
- **Priority**: High
- **Concept**: `TeamLeagueId` (for membership list)
- **Location**: [`Team.leagues`](packages/racing/domain/entities/Team.ts:18), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:124)
- **Why VO**: Array of league IDs; currently `string[]` with no per-item validation; could leverage `LeagueId` VO and a small collection abstraction.
- **Priority**: Medium
---
## Summary of Highest-Impact Candidates (Not Yet Refactored)
The following are **high-priority** candidates that have not been refactored in this pass but are strong future VO targets:
- `LeagueId`, `RaceId`, `TeamId`, and their foreign key counterparts (`RaceLeagueId`, `RaceTrackId`, `RaceCarId`, `TeamLeagueId`).
- Cross-bounded-context identifiers: `SponsorId` in identity linking to racing `Sponsor`, `PreferenceOwnerId` / `NotificationPreferenceId` in notifications, and remaining analytics/session identifiers where primitive usage persists across boundaries.
- URL-related concepts beyond those refactored in this pass: `LeagueSocialLinkUrl` variants, `TrackImageUrl`, `ReferrerUrl`, `ActionUrl` in notifications, and avatar-related URLs in media (where not yet wrapped).
- Time-window and scheduling primitives: `QuietHours` numeric start/end in notifications, and other time-related raw numbers in stewarding settings and session configuration where richer semantics may help.
These should be considered for future VO-focused refactors once the impact on mappers, repositories, and application layers is planned and coordinated.

View File

@@ -1,4 +1,6 @@
export * from './result/Result';
export * as application from './application';
export * as domain from './domain';
export * as errors from './errors';
export * as errors from './errors';
export * from './presentation';
export * from './application/AsyncUseCase';

View File

@@ -0,0 +1,5 @@
export interface Presenter<InputDTO, ViewModel> {
present(input: InputDTO): void;
getViewModel(): ViewModel | null;
reset(): void;
}

View File

@@ -0,0 +1 @@
export * from './Presenter';

View File

@@ -1,4 +1,9 @@
import type { FeedItemType } from '../../domain/value-objects/FeedItemType';
export type FeedItemType =
| 'race_result'
| 'championship_standing'
| 'league_announcement'
| 'friend_joined_league'
| 'friend_won_race';
export interface FeedItemDTO {
id: string;