rename to core
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export interface AutomationEngineValidationResultDTO {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
5
core/automation/application/dto/AutomationResultDTO.ts
Normal file
5
core/automation/application/dto/AutomationResultDTO.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface AutomationResultDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutConfirmationRequestDTO {
|
||||
price: CheckoutPrice;
|
||||
state: CheckoutState;
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
8
core/automation/application/dto/CheckoutInfoDTO.ts
Normal file
8
core/automation/application/dto/CheckoutInfoDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutInfoDTO {
|
||||
price: CheckoutPrice | null;
|
||||
state: CheckoutState;
|
||||
buttonHtml: string;
|
||||
}
|
||||
5
core/automation/application/dto/ClickResultDTO.ts
Normal file
5
core/automation/application/dto/ClickResultDTO.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ClickResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
}
|
||||
6
core/automation/application/dto/FormFillResultDTO.ts
Normal file
6
core/automation/application/dto/FormFillResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface FormFillResultDTO extends AutomationResultDTO {
|
||||
fieldName: string;
|
||||
valueSet: string;
|
||||
}
|
||||
6
core/automation/application/dto/ModalResultDTO.ts
Normal file
6
core/automation/application/dto/ModalResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ModalResultDTO extends AutomationResultDTO {
|
||||
stepId: number;
|
||||
action: string;
|
||||
}
|
||||
6
core/automation/application/dto/NavigationResultDTO.ts
Normal file
6
core/automation/application/dto/NavigationResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface NavigationResultDTO extends AutomationResultDTO {
|
||||
url: string;
|
||||
loadTime: number;
|
||||
}
|
||||
11
core/automation/application/dto/SessionDTO.ts
Normal file
11
core/automation/application/dto/SessionDTO.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
|
||||
export interface SessionDTO {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
currentStep: number;
|
||||
config: HostedSessionConfig;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
7
core/automation/application/dto/WaitResultDTO.ts
Normal file
7
core/automation/application/dto/WaitResultDTO.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface WaitResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
waitedMs: number;
|
||||
found: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Port for authentication services implementing zero-knowledge login.
|
||||
*
|
||||
* GridPilot never sees, stores, or transmits user credentials.
|
||||
* Authentication is handled by opening a visible browser window where
|
||||
* the user logs in directly with iRacing. GridPilot only observes
|
||||
* URL changes to detect successful authentication.
|
||||
*/
|
||||
export interface AuthenticationServicePort {
|
||||
/**
|
||||
* Check if user has a valid session without prompting login.
|
||||
* Navigates to a protected iRacing page and checks for login redirects.
|
||||
*
|
||||
* @returns Result containing the current authentication state
|
||||
*/
|
||||
checkSession(): Promise<Result<AuthenticationState>>;
|
||||
|
||||
/**
|
||||
* Open browser for user to login manually.
|
||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||
* GridPilot waits for URL change indicating successful login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
initiateLogin(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Clear the persistent session (logout).
|
||||
* Removes stored browser context and cookies.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
clearSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get current authentication state.
|
||||
* Returns cached state without making network requests.
|
||||
*
|
||||
* @returns The current AuthenticationState
|
||||
*/
|
||||
getState(): AuthenticationState;
|
||||
|
||||
/**
|
||||
* Validate session with server-side check.
|
||||
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
|
||||
*
|
||||
* @returns Result containing true if server confirms validity, false otherwise
|
||||
*/
|
||||
validateServerSide(): Promise<Result<boolean>>;
|
||||
|
||||
/**
|
||||
* Refresh session state from cookie store.
|
||||
* Re-reads cookies and updates internal state without server validation.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
refreshSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get session expiry date.
|
||||
* Returns the expiry time extracted from session cookies.
|
||||
*
|
||||
* @returns Result containing the expiry Date or null if no expiration
|
||||
*/
|
||||
getSessionExpiry(): Promise<Result<Date | null>>;
|
||||
|
||||
/**
|
||||
* Verify browser page shows authenticated state.
|
||||
* Checks page content for authentication indicators.
|
||||
*/
|
||||
verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>>;
|
||||
}
|
||||
11
core/automation/application/ports/AutomationEnginePort.ts
Normal file
11
core/automation/application/ports/AutomationEnginePort.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';
|
||||
import type { IBrowserAutomation } from './ScreenAutomationPort';
|
||||
|
||||
export interface AutomationEnginePort {
|
||||
validateConfiguration(config: HostedSessionConfig): Promise<AutomationEngineValidationResultDTO>;
|
||||
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
|
||||
stopAutomation(): void;
|
||||
readonly browserAutomation: IBrowserAutomation;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface AutomationEventPublisherPort {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
5
core/automation/application/ports/AutomationResults.ts
Normal file
5
core/automation/application/ports/AutomationResults.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface AutomationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
|
||||
import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO';
|
||||
|
||||
export interface CheckoutConfirmationPort {
|
||||
requestCheckoutConfirmation(
|
||||
request: CheckoutConfirmationRequestDTO
|
||||
): Promise<Result<CheckoutConfirmation>>;
|
||||
}
|
||||
7
core/automation/application/ports/CheckoutServicePort.ts
Normal file
7
core/automation/application/ports/CheckoutServicePort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
|
||||
|
||||
export interface CheckoutServicePort {
|
||||
extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>>;
|
||||
proceedWithCheckout(): Promise<Result<void>>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface IAutomationEventPublisher {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
7
core/automation/application/ports/ILogger.ts
Normal file
7
core/automation/application/ports/ILogger.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ILogger {
|
||||
debug(message: string, context?: Record<string, any>): void;
|
||||
info(message: string, context?: Record<string, any>): void;
|
||||
warn(message: string, context?: Record<string, any>): void;
|
||||
error(message: string, error?: Error, context?: Record<string, any>): void;
|
||||
verbose?(message: string, context?: Record<string, any>): void;
|
||||
}
|
||||
7
core/automation/application/ports/IOverlaySyncPort.ts
Normal file
7
core/automation/application/ports/IOverlaySyncPort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface IOverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
17
core/automation/application/ports/LoggerContext.ts
Normal file
17
core/automation/application/ports/LoggerContext.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Contextual metadata attached to log entries
|
||||
*/
|
||||
export interface LogContext {
|
||||
/** Unique session identifier for correlation */
|
||||
sessionId?: string;
|
||||
/** Current automation step (1-18) */
|
||||
stepId?: number;
|
||||
/** Step name for human readability */
|
||||
stepName?: string;
|
||||
/** Adapter or component name */
|
||||
adapter?: string;
|
||||
/** Operation duration in milliseconds */
|
||||
durationMs?: number;
|
||||
/** Additional arbitrary metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
4
core/automation/application/ports/LoggerLogLevel.ts
Normal file
4
core/automation/application/ports/LoggerLogLevel.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Log levels in order of severity (lowest to highest)
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
16
core/automation/application/ports/LoggerPort.ts
Normal file
16
core/automation/application/ports/LoggerPort.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { LogLevel } from './LoggerLogLevel';
|
||||
import type { LogContext } from './LoggerContext';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
/**
|
||||
* LoggerPort - Port interface for application-layer logging.
|
||||
*/
|
||||
export interface LoggerPort extends ILogger {
|
||||
debug(message: string, context?: LogContext): void;
|
||||
info(message: string, context?: LogContext): void;
|
||||
warn(message: string, context?: LogContext): void;
|
||||
error(message: string, error?: Error, context?: LogContext): void;
|
||||
fatal(message: string, error?: Error, context?: LogContext): void;
|
||||
child(context: LogContext): LoggerPort;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
7
core/automation/application/ports/OverlaySyncPort.ts
Normal file
7
core/automation/application/ports/OverlaySyncPort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface OverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
62
core/automation/application/ports/ScreenAutomationPort.ts
Normal file
62
core/automation/application/ports/ScreenAutomationPort.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { NavigationResultDTO } from '../dto/NavigationResultDTO';
|
||||
import type { ClickResultDTO } from '../dto/ClickResultDTO';
|
||||
import type { WaitResultDTO } from '../dto/WaitResultDTO';
|
||||
import type { ModalResultDTO } from '../dto/ModalResultDTO';
|
||||
import type { AutomationResultDTO } from '../dto/AutomationResultDTO';
|
||||
import type { FormFillResultDTO } from '../dto/FormFillResultDTO';
|
||||
|
||||
/**
|
||||
* Browser automation interface for Playwright-based automation.
|
||||
*
|
||||
* This interface defines the contract for browser automation using
|
||||
* standard DOM manipulation via Playwright. All automation is done
|
||||
* through browser DevTools protocol - no OS-level automation.
|
||||
*/
|
||||
export interface IBrowserAutomation {
|
||||
/**
|
||||
* Navigate to a URL.
|
||||
*/
|
||||
navigateToPage(url: string): Promise<NavigationResultDTO>;
|
||||
|
||||
/**
|
||||
* Fill a form field by name or selector.
|
||||
*/
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO>;
|
||||
|
||||
/**
|
||||
* Click an element by selector or action name.
|
||||
*/
|
||||
clickElement(target: string): Promise<ClickResultDTO>;
|
||||
|
||||
/**
|
||||
* Wait for an element to appear.
|
||||
*/
|
||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO>;
|
||||
|
||||
/**
|
||||
* Handle modal dialogs.
|
||||
*/
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResultDTO>;
|
||||
|
||||
/**
|
||||
* Execute a complete workflow step.
|
||||
*/
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Initialize the browser connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Clean up browser resources.
|
||||
*/
|
||||
disconnect?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if browser is connected and ready.
|
||||
*/
|
||||
isConnected?(): boolean;
|
||||
}
|
||||
11
core/automation/application/ports/SessionRepositoryPort.ts
Normal file
11
core/automation/application/ports/SessionRepositoryPort.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import { SessionStateValue } from '../../domain/value-objects/SessionState';
|
||||
|
||||
export interface SessionRepositoryPort {
|
||||
save(session: AutomationSession): Promise<void>;
|
||||
findById(id: string): Promise<AutomationSession | null>;
|
||||
update(session: AutomationSession): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findAll(): Promise<AutomationSession[]>;
|
||||
findByState(state: SessionStateValue): Promise<AutomationSession[]>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Result } from '../../../shared/result/Result';
|
||||
|
||||
export interface SessionValidatorPort {
|
||||
validateSession(): Promise<Result<boolean>>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IUserConfirmationPort {
|
||||
confirm(message: string): Promise<boolean>;
|
||||
}
|
||||
131
core/automation/application/services/OverlaySyncService.ts
Normal file
131
core/automation/application/services/OverlaySyncService.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncPort';
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||
import { LoggerPort } from '../ports/LoggerPort';
|
||||
import type { IAsyncApplicationService } from '@gridpilot/shared/application';
|
||||
|
||||
type ConstructorArgs = {
|
||||
lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
publisher: AutomationEventPublisherPort
|
||||
logger: LoggerPort
|
||||
initialPanelWaitMs?: number
|
||||
maxPanelRetries?: number
|
||||
backoffFactor?: number
|
||||
defaultTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class OverlaySyncService
|
||||
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
||||
{
|
||||
private lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
private publisher: AutomationEventPublisherPort
|
||||
private logger: LoggerPort
|
||||
private initialPanelWaitMs: number
|
||||
private maxPanelRetries: number
|
||||
private backoffFactor: number
|
||||
private defaultTimeoutMs: number
|
||||
|
||||
constructor(args: ConstructorArgs) {
|
||||
this.lifecycleEmitter = args.lifecycleEmitter
|
||||
this.publisher = args.publisher
|
||||
this.logger = args.logger
|
||||
this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500
|
||||
this.maxPanelRetries = args.maxPanelRetries ?? 3
|
||||
this.backoffFactor = args.backoffFactor ?? 2
|
||||
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
|
||||
}
|
||||
|
||||
async execute(action: OverlayAction): Promise<ActionAck> {
|
||||
return this.startAction(action)
|
||||
}
|
||||
|
||||
async startAction(action: OverlayAction): Promise<ActionAck> {
|
||||
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
|
||||
const seenEvents: AutomationEvent[] = []
|
||||
let settled = false
|
||||
|
||||
const cb: LifecycleCallback = async (ev) => {
|
||||
seenEvents.push(ev)
|
||||
if (ev.type === 'action-started' && ev.actionId === action.id) {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveAck({ id: action.id, status: 'confirmed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
this.lifecycleEmitter.offLifecycle(cb)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let resolveAck: (ack: ActionAck) => void = () => {}
|
||||
const promise = new Promise<ActionAck>((resolve) => {
|
||||
resolveAck = resolve
|
||||
try {
|
||||
this.lifecycleEmitter.onLifecycle(cb)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
|
||||
actionId: action.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
void this.publisher.publish({
|
||||
type: 'modal-opened',
|
||||
timestamp: Date.now(),
|
||||
payload: { actionId: action.id, label: action.label },
|
||||
actionId: action.id,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
|
||||
actionId: action.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise<ActionAck>((res) => {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
|
||||
actionId: action.id,
|
||||
timeoutMs,
|
||||
})
|
||||
const lastEvents = seenEvents.slice(-10)
|
||||
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
|
||||
actionId: action.id,
|
||||
events: lastEvents,
|
||||
})
|
||||
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
}
|
||||
|
||||
async cancelAction(actionId: string): Promise<void> {
|
||||
try {
|
||||
await this.publisher.publish({
|
||||
type: 'panel-missing',
|
||||
timestamp: Date.now(),
|
||||
actionId,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
|
||||
actionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
|
||||
import type { SessionValidatorPort } from '../ports/SessionValidatorPort';
|
||||
|
||||
/**
|
||||
* Use case for checking if the user has a valid iRacing session.
|
||||
*
|
||||
* This validates the session before automation starts, allowing
|
||||
* the system to prompt for re-authentication if needed.
|
||||
*
|
||||
* Implements hybrid validation strategy:
|
||||
* - File-based validation (fast, always executed)
|
||||
* - Optional server-side validation (slow, requires network)
|
||||
*/
|
||||
export class CheckAuthenticationUseCase {
|
||||
constructor(
|
||||
private readonly logger: ILogger,
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly sessionValidator?: SessionValidatorPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the authentication check.
|
||||
*
|
||||
* @param options Optional configuration for validation
|
||||
* @returns Result containing the current AuthenticationState
|
||||
*/
|
||||
async execute(options?: {
|
||||
requireServerValidation?: boolean;
|
||||
verifyPageContent?: boolean;
|
||||
}): Promise<Result<AuthenticationState>> {
|
||||
this.logger.debug('Executing CheckAuthenticationUseCase', { options });
|
||||
try {
|
||||
// Step 1: File-based validation (fast)
|
||||
this.logger.debug('Performing file-based authentication check.');
|
||||
const fileResult = await this.authService.checkSession();
|
||||
if (fileResult.isErr()) {
|
||||
this.logger.error('File-based authentication check failed.', { error: fileResult.unwrapErr() });
|
||||
return fileResult;
|
||||
}
|
||||
this.logger.info('File-based authentication check succeeded.');
|
||||
|
||||
const fileState = fileResult.unwrap();
|
||||
this.logger.debug(`File-based authentication state: ${fileState}`);
|
||||
|
||||
// Step 2: Check session expiry if authenticated
|
||||
if (fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Session is authenticated, checking expiry.');
|
||||
const expiryResult = await this.authService.getSessionExpiry();
|
||||
if (expiryResult.isErr()) {
|
||||
this.logger.warn('Could not retrieve session expiry, proceeding with file-based state.', { error: expiryResult.unwrapErr() });
|
||||
// Don't fail completely if we can't get expiry, use file-based state
|
||||
return Result.ok(fileState);
|
||||
}
|
||||
|
||||
const expiry = expiryResult.unwrap();
|
||||
if (expiry !== null) {
|
||||
try {
|
||||
const sessionLifetime = new SessionLifetime(expiry);
|
||||
if (sessionLifetime.isExpired()) {
|
||||
this.logger.info('Session has expired based on lifetime.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.debug('Session is not expired.');
|
||||
} catch (error) {
|
||||
this.logger.error('Invalid expiry date encountered, treating session as expired.', { expiry, error });
|
||||
// Invalid expiry date, treat as expired for safety
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Optional page content verification
|
||||
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional page content verification.');
|
||||
const pageResult = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (pageResult.isOk()) {
|
||||
const browserState = pageResult.unwrap();
|
||||
// If cookies valid but page shows login UI, session is expired
|
||||
if (!browserState.isFullyAuthenticated()) {
|
||||
this.logger.info('Page content verification indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Page content verification succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Page content verification failed, proceeding with file-based state.', { error: pageResult.unwrapErr() });
|
||||
}
|
||||
// Don't block on page verification errors, continue with file-based state
|
||||
}
|
||||
|
||||
// Step 4: Optional server-side validation
|
||||
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional server-side validation.');
|
||||
const serverResult = await this.sessionValidator.validateSession();
|
||||
|
||||
// Don't block on server validation errors
|
||||
if (serverResult.isOk()) {
|
||||
const isValid = serverResult.unwrap();
|
||||
if (!isValid) {
|
||||
this.logger.info('Server-side validation indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Server-side validation succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Server-side validation failed, proceeding with file-based state.', { error: serverResult.unwrapErr() });
|
||||
}
|
||||
}
|
||||
this.logger.info(`CheckAuthenticationUseCase completed successfully with state: ${fileState}`);
|
||||
return Result.ok(fileState);
|
||||
} catch (error) {
|
||||
this.logger.error('An unexpected error occurred during authentication check.', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
core/automation/application/use-cases/ClearSessionUseCase.ts
Normal file
47
core/automation/application/use-cases/ClearSessionUseCase.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for clearing the user's session (logout).
|
||||
*
|
||||
* Removes stored browser context and cookies, effectively logging
|
||||
* the user out. The next automation attempt will require re-authentication.
|
||||
*/
|
||||
export class ClearSessionUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger, // Inject ILogger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the session clearing.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Attempting to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
try {
|
||||
const result = await this.authService.clearSession();
|
||||
|
||||
if (result.isSuccess) {
|
||||
this.logger.info('User session cleared successfully.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('Failed to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase',
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error clearing user session.', error, {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
return Result.fail(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
export class CompleteRaceCreationUseCase {
|
||||
constructor(private readonly checkoutService: CheckoutServicePort, private readonly logger: ILogger) {}
|
||||
|
||||
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
|
||||
this.logger.debug(`Attempting to complete race creation for session ID: ${sessionId}`);
|
||||
if (!sessionId || sessionId.trim() === '') {
|
||||
this.logger.error('Session ID is required for completing race creation.');
|
||||
return Result.err(new Error('Session ID is required'));
|
||||
}
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error(`Failed to extract checkout info: ${infoResult.unwrapErr().message}`);
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.debug(`Extracted checkout information: ${JSON.stringify(info)}`);
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page.');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
try {
|
||||
const raceCreationResult = RaceCreationResult.create({
|
||||
sessionId,
|
||||
price: info.price.toDisplayString(),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
this.logger.info(`Race creation completed successfully for session ID: ${sessionId}`);
|
||||
return Result.ok(raceCreationResult);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
this.logger.error(`Error completing race creation for session ID ${sessionId}: ${err.message}`);
|
||||
return Result.err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';
|
||||
import { CheckoutStateEnum } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
interface SessionMetadata {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
}
|
||||
|
||||
export class ConfirmCheckoutUseCase {
|
||||
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
constructor(
|
||||
private readonly checkoutService: CheckoutServicePort,
|
||||
private readonly confirmationPort: CheckoutConfirmationPort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
|
||||
this.logger.debug('Executing ConfirmCheckoutUseCase', { sessionMetadata });
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error('Failed to extract checkout info', { error: infoResult.unwrapErr() });
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.info('Extracted checkout info', { state: info.state.getValue(), price: info.price });
|
||||
|
||||
|
||||
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
|
||||
this.logger.error('Insufficient funds to complete checkout');
|
||||
return Result.err(new Error('Insufficient funds to complete checkout'));
|
||||
}
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
this.logger.debug('Requesting checkout confirmation', { price: info.price, state: info.state.getValue(), sessionMetadata });
|
||||
|
||||
// Request confirmation via port with full checkout context
|
||||
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
|
||||
price: info.price,
|
||||
state: info.state,
|
||||
sessionMetadata: sessionMetadata || {
|
||||
sessionName: 'Unknown Session',
|
||||
trackId: 'unknown',
|
||||
carIds: [],
|
||||
},
|
||||
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (confirmationResult.isErr()) {
|
||||
this.logger.error('Checkout confirmation failed', { error: confirmationResult.unwrapErr() });
|
||||
return Result.err(confirmationResult.unwrapErr());
|
||||
}
|
||||
|
||||
const confirmation = confirmationResult.unwrap();
|
||||
this.logger.info('Checkout confirmation received', { confirmation });
|
||||
|
||||
if (confirmation.isCancelled()) {
|
||||
this.logger.error('Checkout cancelled by user');
|
||||
return Result.err(new Error('Checkout cancelled by user'));
|
||||
}
|
||||
|
||||
if (confirmation.isTimeout()) {
|
||||
this.logger.error('Checkout confirmation timeout');
|
||||
return Result.err(new Error('Checkout confirmation timeout'));
|
||||
}
|
||||
|
||||
this.logger.info('Proceeding with checkout');
|
||||
const checkoutResult = await this.checkoutService.proceedWithCheckout();
|
||||
|
||||
if (checkoutResult.isOk()) {
|
||||
this.logger.info('Checkout process completed successfully.');
|
||||
} else {
|
||||
this.logger.error('Checkout process failed', { error: checkoutResult.unwrapErr() });
|
||||
}
|
||||
return checkoutResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { ILogger } from '../../../shared/logger/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for initiating the manual login flow.
|
||||
*
|
||||
* Opens a visible browser window where the user can log into iRacing directly.
|
||||
* GridPilot never sees the credentials - it only waits for the URL to change
|
||||
* indicating successful login.
|
||||
*/
|
||||
export class InitiateLoginUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the login flow.
|
||||
* Opens browser and waits for user to complete manual login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Initiating login flow...');
|
||||
try {
|
||||
const result = await this.authService.initiateLogin();
|
||||
if (result.isOk()) {
|
||||
this.logger.info('Login flow initiated successfully.');
|
||||
} else {
|
||||
this.logger.warn('Login flow initiation failed.', { error: result.error });
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error initiating login flow.', error);
|
||||
return Result.fail(error.message || 'Unknown error during login initiation.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { AutomationEnginePort } from '../ports/AutomationEnginePort';
|
||||
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
|
||||
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
|
||||
import type { SessionDTO } from '../dto/SessionDTO';
|
||||
|
||||
export class StartAutomationSessionUseCase
|
||||
implements AsyncUseCase<HostedSessionConfig, SessionDTO> {
|
||||
constructor(
|
||||
private readonly automationEngine: AutomationEnginePort,
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: SessionRepositoryPort,
|
||||
private readonly logger: ILogger
|
||||
) {}
|
||||
|
||||
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
|
||||
this.logger.debug('Starting automation session execution', { config });
|
||||
|
||||
const session = AutomationSession.create(config);
|
||||
this.logger.info(`Automation session created with ID: ${session.id}`);
|
||||
|
||||
const validationResult = await this.automationEngine.validateConfiguration(config);
|
||||
if (!validationResult.isValid) {
|
||||
this.logger.warn('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
this.logger.error('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
throw new Error(validationResult.error);
|
||||
}
|
||||
this.logger.debug('Automation session configuration validated successfully.');
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
this.logger.info(`Automation session with ID: ${session.id} saved to repository.`);
|
||||
|
||||
const dto: SessionDTO = {
|
||||
sessionId: session.id,
|
||||
state: session.state.value,
|
||||
currentStep: session.currentStep.value,
|
||||
config: session.config,
|
||||
...(session.startedAt ? { startedAt: session.startedAt } : {}),
|
||||
...(session.completedAt ? { completedAt: session.completedAt } : {}),
|
||||
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
|
||||
};
|
||||
|
||||
this.logger.debug('Automation session executed successfully, returning DTO.', { dto });
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for verifying browser shows authenticated page state.
|
||||
* Combines cookie validation with page content verification.
|
||||
*/
|
||||
export class VerifyAuthenticatedPageUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<BrowserAuthenticationState>> {
|
||||
this.logger.debug('Executing VerifyAuthenticatedPageUseCase');
|
||||
try {
|
||||
const result = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.error ?? new Error('Page verification failed');
|
||||
this.logger.error(`Page verification failed: ${error.message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(error);
|
||||
}
|
||||
|
||||
const browserState = result.unwrap();
|
||||
this.logger.info('Successfully verified authenticated page state.');
|
||||
return Result.ok<BrowserAuthenticationState>(browserState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Page verification failed unexpectedly: ${message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user