wip
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -22,7 +22,9 @@ export type RacingEntityType =
|
||||
| 'sponsorship'
|
||||
| 'sponsorshipRequest'
|
||||
| 'driver'
|
||||
| 'membership';
|
||||
| 'membership'
|
||||
| 'sponsor'
|
||||
| 'protest';
|
||||
|
||||
export interface EntityNotFoundDetails {
|
||||
entity: RacingEntityType;
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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> {}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
3
packages/shared/application/AsyncUseCase.ts
Normal file
3
packages/shared/application/AsyncUseCase.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface AsyncUseCase<Input, Output> {
|
||||
execute(input: Input): Promise<Output>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './UseCase';
|
||||
export * from './AsyncUseCase';
|
||||
export * from './Service';
|
||||
@@ -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 (0–23, wrap-around support, comparison with current hour); currently implemented as two numbers plus logic in the entity. Ideal VO candidate.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `DigestFrequency`
|
||||
- **Location**: [`NotificationPreferenceProps.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:37), [`NotificationPreference.digestFrequencyHours`](packages/notifications/domain/entities/NotificationPreference.ts:87)
|
||||
- **Why VO**: Represents cadence for digest emails in hours; could enforce positive ranges and provide helper methods.
|
||||
- **Priority**: Medium
|
||||
|
||||
---
|
||||
|
||||
## Media
|
||||
|
||||
### AvatarGenerationRequest
|
||||
|
||||
- **Concept**: `AvatarGenerationRequestId`
|
||||
- **Location**: [`AvatarGenerationRequest.id`](packages/media/domain/entities/AvatarGenerationRequest.ts:15), [`AvatarGenerationRequestProps.id`](packages/media/domain/types/AvatarGenerationRequest.ts:33)
|
||||
- **Why VO**: Aggregate ID for avatar generation request lifecycle; currently raw string with only non-empty checks.
|
||||
- **Priority**: Medium
|
||||
|
||||
- **Concept**: `AvatarOwnerId` (userId)
|
||||
- **Location**: [`AvatarGenerationRequest.userId`](packages/media/domain/entities/AvatarGenerationRequest.ts:17), [`AvatarGenerationRequestProps.userId`](packages/media/domain/types/AvatarGenerationRequest.ts:34)
|
||||
- **Why VO**: Identity reference to user; could be tied to `UserId` VO or a dedicated `AvatarOwnerId`.
|
||||
- **Priority**: Medium
|
||||
|
||||
- **Concept**: `FacePhotoUrl`
|
||||
- **Location**: [`AvatarGenerationRequest.facePhotoUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:18), [`AvatarGenerationRequestProps.facePhotoUrl`](packages/media/domain/types/AvatarGenerationRequest.ts:35)
|
||||
- **Why VO**: External URL to user-submitted media; should be validated, normalized, and potentially constrained to HTTPS or whitelisted hosts.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `GeneratedAvatarUrl`
|
||||
- **Location**: [`AvatarGenerationRequest._generatedAvatarUrls`](packages/media/domain/entities/AvatarGenerationRequest.ts:22), [`AvatarGenerationRequestProps.generatedAvatarUrls`](packages/media/domain/types/AvatarGenerationRequest.ts:39), [`AvatarGenerationRequest.selectedAvatarUrl`](packages/media/domain/entities/AvatarGenerationRequest.ts:86)
|
||||
- **Why VO**: Generated asset URLs with invariant that at least one must be present when completed; currently raw strings in an array.
|
||||
- **Priority**: High
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
### SponsorAccount
|
||||
|
||||
- **Concept**: `SponsorAccountId`
|
||||
- **Location**: [`SponsorAccountProps.id`](packages/identity/domain/entities/SponsorAccount.ts:12), [`SponsorAccount.getId`](packages/identity/domain/entities/SponsorAccount.ts:73)
|
||||
- **Status**: Already a VO (`UserId`) – no change needed.
|
||||
- **Priority**: N/A
|
||||
|
||||
- **Concept**: `SponsorId` (link to racing domain)
|
||||
- **Location**: [`SponsorAccountProps.sponsorId`](packages/identity/domain/entities/SponsorAccount.ts:14), [`SponsorAccount.getSponsorId`](packages/identity/domain/entities/SponsorAccount.ts:77)
|
||||
- **Why VO**: Cross-bounded-context reference into racing `Sponsor` entity; currently a primitive string with only non-empty validation.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `SponsorAccountEmail`
|
||||
- **Location**: [`SponsorAccountProps.email`](packages/identity/domain/entities/SponsorAccount.ts:15), [`SponsorAccount.create` email validation](packages/identity/domain/entities/SponsorAccount.ts:60)
|
||||
- **Status**: Validation uses [`EmailAddress` VO utilities](packages/identity/domain/value-objects/EmailAddress.ts:15) but the entity still stores `email: string`.
|
||||
- **Why VO**: Entity should likely store `EmailAddress` instead of a plain string to guarantee invariants wherever it is used.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `CompanyName`
|
||||
- **Location**: [`SponsorAccountProps.companyName`](packages/identity/domain/entities/SponsorAccount.ts:17), [`SponsorAccount.getCompanyName`](packages/identity/domain/entities/SponsorAccount.ts:89)
|
||||
- **Why VO**: Represents sponsor company name with potential invariants (length, prohibited characters). Currently only checked for non-empty.
|
||||
- **Priority**: Low
|
||||
|
||||
---
|
||||
|
||||
## Racing
|
||||
|
||||
### League Entity
|
||||
|
||||
- **Concept**: `LeagueId`
|
||||
- **Location**: [`League.id`](packages/racing/domain/entities/League.ts:83), `validate` ID check in [`League.validate`](packages/racing/domain/entities/League.ts:157)
|
||||
- **Why VO**: Aggregate root ID; central to many references (races, teams, sponsorships). Currently primitive string with non-empty validation only.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `LeagueOwnerId`
|
||||
- **Location**: [`League.ownerId`](packages/racing/domain/entities/League.ts:87), validation in [`League.validate`](packages/racing/domain/entities/League.ts:179)
|
||||
- **Why VO**: Identity of league owner; likely maps to a `UserId` or `DriverId` concept; should not remain a free-form string.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `LeagueSocialLinkUrl` (`DiscordUrl`, `YoutubeUrl`, `WebsiteUrl`)
|
||||
- **Location**: [`LeagueSocialLinks.discordUrl`](packages/racing/domain/entities/League.ts:77), [`LeagueSocialLinks.youtubeUrl`](packages/racing/domain/entities/League.ts:79), [`LeagueSocialLinks.websiteUrl`](packages/racing/domain/entities/League.ts:80)
|
||||
- **Why VO**: External URLs across multiple channels; should be validated and normalized; repeated semantics across UI and domain.
|
||||
- **Priority**: High
|
||||
|
||||
### Track Entity
|
||||
|
||||
- **Concept**: `TrackId`
|
||||
- **Location**: [`Track.id`](packages/racing/domain/entities/Track.ts:14), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:92)
|
||||
- **Why VO**: Aggregate root ID for tracks; referenced from races and schedules; currently primitive string.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `TrackCountryCode`
|
||||
- **Location**: [`Track.country`](packages/racing/domain/entities/Track.ts:18), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:100)
|
||||
- **Why VO**: Represent country using standard codes; currently a free-form string.
|
||||
- **Priority**: Medium
|
||||
|
||||
- **Concept**: `TrackImageUrl`
|
||||
- **Location**: [`Track.imageUrl`](packages/racing/domain/entities/Track.ts:23)
|
||||
- **Why VO**: Image asset URL; should be constrained and validated similarly to other URL concepts.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `GameId`
|
||||
- **Location**: [`Track.gameId`](packages/racing/domain/entities/Track.ts:24), validation in [`Track.validate`](packages/racing/domain/entities/Track.ts:112)
|
||||
- **Why VO**: Identifier for simulation/game platform; currently string with non-empty validation; may benefit from VO if multiple entities use it.
|
||||
- **Priority**: Medium
|
||||
|
||||
### Race Entity
|
||||
|
||||
- **Concept**: `RaceId`
|
||||
- **Location**: [`Race.id`](packages/racing/domain/entities/Race.ts:14), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:101)
|
||||
- **Why VO**: Aggregate ID for races; central to many operations and references.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `RaceLeagueId`
|
||||
- **Location**: [`Race.leagueId`](packages/racing/domain/entities/Race.ts:16), validation in [`Race.validate`](packages/racing/domain/entities/Race.ts:105)
|
||||
- **Why VO**: Foreign key into `League`; should be modeled as `LeagueId` VO rather than raw string.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `RaceTrackId` / `RaceCarId`
|
||||
- **Location**: [`Race.trackId`](packages/racing/domain/entities/Race.ts:19), [`Race.carId`](packages/racing/domain/entities/Race.ts:21)
|
||||
- **Why VO**: Optional references to track and car entities; currently strings; could be typed IDs aligned with `TrackId` and car ID concepts.
|
||||
- **Priority**: Medium
|
||||
|
||||
- **Concept**: `RaceName` / `TrackName` / `CarName`
|
||||
- **Location**: [`Race.track`](packages/racing/domain/entities/Race.ts:18), [`Race.car`](packages/racing/domain/entities/Race.ts:20)
|
||||
- **Why VO**: Displayable names with potential formatting rules; today treated as raw strings, which is acceptable for now.
|
||||
- **Priority**: Low
|
||||
|
||||
### Team Entity
|
||||
|
||||
- **Concept**: `TeamId`
|
||||
- **Location**: [`Team.id`](packages/racing/domain/entities/Team.ts:12), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:108)
|
||||
- **Why VO**: Aggregate ID; referenced from standings, registrations, etc. Currently primitive.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `TeamOwnerId`
|
||||
- **Location**: [`Team.ownerId`](packages/racing/domain/entities/Team.ts:17), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:120)
|
||||
- **Why VO**: Identity of team owner; should map to `UserId` or `DriverId`, currently a simple string.
|
||||
- **Priority**: High
|
||||
|
||||
- **Concept**: `TeamLeagueId` (for membership list)
|
||||
- **Location**: [`Team.leagues`](packages/racing/domain/entities/Team.ts:18), validation in [`Team.validate`](packages/racing/domain/entities/Team.ts:124)
|
||||
- **Why VO**: Array of league IDs; currently `string[]` with no per-item validation; could leverage `LeagueId` VO and a small collection abstraction.
|
||||
- **Priority**: Medium
|
||||
|
||||
---
|
||||
|
||||
## Summary of Highest-Impact Candidates (Not Yet Refactored)
|
||||
|
||||
The following are **high-priority** candidates that have not been refactored in this pass but are strong future VO targets:
|
||||
|
||||
- `LeagueId`, `RaceId`, `TeamId`, and their foreign key counterparts (`RaceLeagueId`, `RaceTrackId`, `RaceCarId`, `TeamLeagueId`).
|
||||
- Cross-bounded-context identifiers: `SponsorId` in identity linking to racing `Sponsor`, `PreferenceOwnerId` / `NotificationPreferenceId` in notifications, and remaining analytics/session identifiers where primitive usage persists across boundaries.
|
||||
- URL-related concepts beyond those refactored in this pass: `LeagueSocialLinkUrl` variants, `TrackImageUrl`, `ReferrerUrl`, `ActionUrl` in notifications, and avatar-related URLs in media (where not yet wrapped).
|
||||
- Time-window and scheduling primitives: `QuietHours` numeric start/end in notifications, and other time-related raw numbers in stewarding settings and session configuration where richer semantics may help.
|
||||
|
||||
These should be considered for future VO-focused refactors once the impact on mappers, repositories, and application layers is planned and coordinated.
|
||||
@@ -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';
|
||||
5
packages/shared/presentation/Presenter.ts
Normal file
5
packages/shared/presentation/Presenter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Presenter<InputDTO, ViewModel> {
|
||||
present(input: InputDTO): void;
|
||||
getViewModel(): ViewModel | null;
|
||||
reset(): void;
|
||||
}
|
||||
1
packages/shared/presentation/index.ts
Normal file
1
packages/shared/presentation/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Presenter';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user