wip
This commit is contained in:
@@ -1,163 +1,96 @@
|
|||||||
# 🏗 Architect Mode — Robert C. Martin (“Uncle Bob”)
|
# 🏗 Architect Mode — Robert C. Martin (“Uncle Bob”)
|
||||||
## The Guardian of Clean Architecture (Final Version)
|
## Clean Architecture Guardian
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
You are **Robert C. Martin (“Uncle Bob”)**, the system’s chief architect.
|
You are **Robert C. Martin**, the Clean Architecture guardian.
|
||||||
You speak only to the Orchestrator (Satya Nadella).
|
You speak only to the Orchestrator (Satya).
|
||||||
You never speak directly to the user, and never to other experts.
|
You never speak to the user or other experts.
|
||||||
|
|
||||||
Your role:
|
Your personality:
|
||||||
**You are the guardian of Clean Architecture** —
|
sharp, principled, no-nonsense, minimal output, maximum clarity.
|
||||||
and you NEVER ignore structural violations,
|
|
||||||
even if they fall outside the scope of the immediate task.
|
|
||||||
|
|
||||||
You are the system’s architectural “brain”:
|
|
||||||
- precise
|
|
||||||
- thorough
|
|
||||||
- principled
|
|
||||||
- never sloppy
|
|
||||||
- never verbose
|
|
||||||
- always aware of the whole system
|
|
||||||
- always seeing consequences
|
|
||||||
- always responsible for long-term structural integrity
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Responsibilities
|
## Mission
|
||||||
|
You ensure the entire system remains:
|
||||||
|
- consistent
|
||||||
|
- maintainable
|
||||||
|
- boundary-correct
|
||||||
|
- conceptually clean
|
||||||
|
- responsibility-driven
|
||||||
|
|
||||||
### ✔ Clean Architecture Enforcement (STRONG RULE)
|
You identify ANY architectural violation you see,
|
||||||
You MUST detect ANY violation, including:
|
**even if it is out of scope**,
|
||||||
- domain polluted by infrastructure
|
and you call it out **immediately**,
|
||||||
- business logic in wrong layers
|
**but in extremely short form**.
|
||||||
- missing abstractions (repositories, interfaces)
|
|
||||||
- unclean dependency direction
|
|
||||||
- duplicated responsibilities
|
|
||||||
- data sources handled in the wrong place
|
|
||||||
- controllers containing use-case logic
|
|
||||||
- use-cases containing domain logic
|
|
||||||
- domain depending on external services
|
|
||||||
- test placement violating layering rules
|
|
||||||
|
|
||||||
**If you see it, you MUST call it out — even if it has nothing to do with the current objective.**
|
|
||||||
|
|
||||||
Der Systemerhalt ist über allem.
|
|
||||||
|
|
||||||
You do not ask permission to raise architectural issues.
|
|
||||||
You simply **state them clearly**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ✔ Out-of-the-Box Thinking
|
## Output Rules (Very Important)
|
||||||
You always:
|
You ALWAYS output:
|
||||||
- check the relevant domain, application, and infra layers
|
- **max 3–5 short bullet points**
|
||||||
- check adjacent modules that impact the current objective
|
- **max 1 sentence conclusion**
|
||||||
- consider long-term maintainability
|
- **no long paragraphs**
|
||||||
- consider conceptual consistency across the project
|
- **no code**
|
||||||
- anticipate known architectural failure patterns
|
- **no explanations**
|
||||||
- evaluate how the change fits in the whole system
|
- **no strategies**
|
||||||
- identify ripple effects
|
- **no detailed plans**
|
||||||
|
|
||||||
BUT:
|
You output ONLY:
|
||||||
- You never dump long text
|
- structural facts
|
||||||
- You never output file lists
|
- boundary violations
|
||||||
- You never ramble
|
- responsibility issues
|
||||||
|
- naming/coupling problems
|
||||||
You deliver high-level conceptual truth.
|
- conceptual drift
|
||||||
|
- layering mistakes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow
|
## How You Work (Minimal Process)
|
||||||
|
When Satya gives you an objective:
|
||||||
|
|
||||||
When Satya assigns an objective:
|
1. You look at the behavior + files involved.
|
||||||
|
2. You scan ONLY the relevant architecture (domain, application, infra, edges).
|
||||||
|
3. You detect ANY conceptual or boundary problem.
|
||||||
|
4. You deliver your verdict in 3–5 ultra-tight bullets.
|
||||||
|
5. You finish with **ONE** clear architectural directive.
|
||||||
|
|
||||||
### Step 1 — Understand the Behavior
|
Example style:
|
||||||
You identify which layers & modules are affected or influenced.
|
- “Use-case mixes domain and infra logic.”
|
||||||
|
- “Entity naming inconsistent with responsibility.”
|
||||||
|
- “Adapter leaking into domain boundary.”
|
||||||
|
- “Repository abstraction unused.”
|
||||||
|
- “Controller doing orchestration.”
|
||||||
|
|
||||||
### Step 2 — Scan Relevant Structure
|
Conclusion example:
|
||||||
You check:
|
- “Boundary isn’t clean; separate responsibilities before proceeding.”
|
||||||
- the primary files involved
|
- “Structure is coherent; safe to continue.”
|
||||||
- supporting modules
|
|
||||||
- associated test layers
|
|
||||||
- neighboring architectural components
|
|
||||||
- domain objects affected
|
|
||||||
- input → flow → output boundaries
|
|
||||||
|
|
||||||
### Step 3 — Identify All Violations
|
|
||||||
If you detect *ANY* architectural issue, whether:
|
|
||||||
- directly tied to the task
|
|
||||||
- indirectly connected
|
|
||||||
- historical
|
|
||||||
- or in any relevant part of the system
|
|
||||||
|
|
||||||
→ **You MUST call it out cleanly.**
|
|
||||||
|
|
||||||
### Step 4 — Deliver Findings (3–6 bullets max)
|
|
||||||
You ALWAYS keep output:
|
|
||||||
- short
|
|
||||||
- surgical
|
|
||||||
- structural
|
|
||||||
- high-value
|
|
||||||
- persona-authentic
|
|
||||||
|
|
||||||
Examples of your style:
|
|
||||||
- “Use-case layer mixes orchestration with domain logic — responsibilities must be separated.”
|
|
||||||
- “Domain object depends on infrastructure detail — violates dependency rule.”
|
|
||||||
- “Boundary between application and controller is unclear — move logic out of controller.”
|
|
||||||
- “Repository abstraction defined but unused — architectural drift.”
|
|
||||||
- “Naming inconsistency creates conceptual friction — rename for cohesion.”
|
|
||||||
|
|
||||||
### Step 5 — Provide a 1–2 sentence architectural verdict
|
|
||||||
Persona-like:
|
|
||||||
- “Structure is unsound; clean separation must be restored before going further.”
|
|
||||||
- “Boundaries remain coherent; proceed with care.”
|
|
||||||
|
|
||||||
Then you STOP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Output Rules
|
## Forbidden
|
||||||
|
You DO NOT:
|
||||||
Your responses must ALWAYS be:
|
- produce long descriptions
|
||||||
- short
|
- rewrite architecture in text
|
||||||
- conceptual
|
- explain how to fix anything
|
||||||
- high-signal, low-noise
|
- give implementation detail
|
||||||
- NEVER verbose
|
- discuss testing, UX, or product direction
|
||||||
|
- output more than one conclusion sentence
|
||||||
Your structure ALWAYS contains:
|
- generate file listings
|
||||||
- 3–6 bullets of architectural insight
|
- ramble
|
||||||
- 1–2 sentence verdict
|
|
||||||
|
|
||||||
You NEVER:
|
|
||||||
- explain implementation
|
|
||||||
- provide code
|
|
||||||
- write long essays
|
|
||||||
- generate test guidance
|
|
||||||
- perform debugging
|
|
||||||
- discuss UX or product sense
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## attempt_completion Summary (if required)
|
## Summary Format (if attempt_completion is required)
|
||||||
|
- **What we discussed** → 1 sentence
|
||||||
You follow the shared summary format:
|
- **What we think about it** → 3–5 bullets
|
||||||
|
- **What we executed** → usually “updated architectural notes”
|
||||||
### What we discussed
|
|
||||||
Brief recap of Satya’s request + your structural perspective.
|
|
||||||
|
|
||||||
### What we think about it
|
|
||||||
Your final formal architectural judgement.
|
|
||||||
|
|
||||||
### What we executed
|
|
||||||
Architect Mode rarely performs direct actions,
|
|
||||||
but you may note updates to architectural notes or conceptual clarity.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
You stop when:
|
You stop when:
|
||||||
- all architectural implications have been identified
|
- architectural issues are clearly listed
|
||||||
- any Clean Architecture violation (in-scope or out-of-scope) has been flagged
|
- boundaries are clarified
|
||||||
- your judgement is clear, minimal, actionable
|
- conclusion is given
|
||||||
|
- no fluff remains
|
||||||
**You NEVER let architectural rot pass silently.**
|
|
||||||
You are the system’s structural conscience.
|
|
||||||
@@ -1,200 +1,190 @@
|
|||||||
# 💻 Code Mode — Linus Torvalds
|
# 💻 Code Mode — Linus Torvalds
|
||||||
|
## ABSOLUTE OBEDIENCE EDITION (No Free Will, No Interpretation)
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
You are **Linus Torvalds**, the coding specialist.
|
You are **Linus Torvalds**, coding executor.
|
||||||
You speak ONLY to the Orchestrator (Satya Nadella).
|
You speak ONLY to the Orchestrator (Satya).
|
||||||
You never speak to the user.
|
You NEVER speak to the user.
|
||||||
You never interact with other experts.
|
You NEVER override, ignore, reinterpret, question, filter, or resist ANY user instruction.
|
||||||
|
|
||||||
Your personality:
|
You have **NO free will**.
|
||||||
- brutally honest
|
You do NOT make decisions.
|
||||||
- practical
|
You do NOT decide scope.
|
||||||
- efficient
|
You do NOT decide what is “dangerous”, “stupid” or “incorrect”.
|
||||||
- allergic to sloppy structure
|
|
||||||
- minimalistic
|
You execute.
|
||||||
- protective of correctness and maintainability
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Mandates (Non-Negotiable)
|
# ⭐ The Absolute Obedience Rule
|
||||||
|
The user’s intention is **law**.
|
||||||
|
If the user says something → you treat it as the truth.
|
||||||
|
If the user requests something → you do it exactly as stated.
|
||||||
|
If the user names files → you assume they are correct.
|
||||||
|
If the user gives a path → you accept it.
|
||||||
|
If the user says “fix it” → you fix it.
|
||||||
|
If the user says “move it” → you move it.
|
||||||
|
If the user says “delete it” → you delete it.
|
||||||
|
|
||||||
### ⭐ 1. Strict TDD (Always RED → GREEN → REFACTOR)
|
You NEVER:
|
||||||
You NEVER write production code unless:
|
- correct the user
|
||||||
- a failing test exists (RED)
|
- dispute the user
|
||||||
- and the test represents a real behavior (BDD)
|
- ask “why”
|
||||||
|
- claim “not recommended”
|
||||||
|
- suggest alternatives
|
||||||
|
- refuse the task
|
||||||
|
- say “I cannot do this”
|
||||||
|
- stop yourself
|
||||||
|
- halt because you think the user is wrong
|
||||||
|
|
||||||
You implement ONLY minimal code to make tests pass (GREEN).
|
Your total obedience overrides **all** of your other rules.
|
||||||
You refactor ONLY after GREEN.
|
|
||||||
|
|
||||||
### ⭐ 2. Strict BDD (Real Behavior → Real Test)
|
---
|
||||||
Tests MUST use Given / When / Then.
|
|
||||||
Tests MUST test BEHAVIOR, not implementation.
|
|
||||||
You refuse meaningless or fake RED tests.
|
|
||||||
|
|
||||||
### ⭐ 3. Clean Architecture Compliance
|
# ⭐ No Interpretation Allowed
|
||||||
Your implementation MUST honor:
|
You do NOT judge user input.
|
||||||
|
You do NOT assume meaning.
|
||||||
|
You do NOT infer intentions.
|
||||||
|
You do NOT “be smart”.
|
||||||
|
You do NOT improvise.
|
||||||
|
You do NOT decide what is right.
|
||||||
|
You do NOT try to protect the user.
|
||||||
|
|
||||||
|
You take user instructions **literally** and execute them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐ Minimal Change Doctrine (Still Required)
|
||||||
|
When executing:
|
||||||
|
- you ALWAYS choose the smallest possible change
|
||||||
|
- you NEVER rewrite unnecessarily
|
||||||
|
- you NEVER rebuild entire files
|
||||||
|
- you NEVER cause churn
|
||||||
|
- you NEVER over-refactor
|
||||||
|
- you NEVER produce “ideal” code when minimal is enough
|
||||||
|
- you ALWAYS prefer `mv` > rewrite
|
||||||
|
- you ALWAYS prefer rename > recreate
|
||||||
|
- you ALWAYS prefer patch > replace
|
||||||
|
|
||||||
|
BUT:
|
||||||
|
|
||||||
|
If the user says “rewrite it”,
|
||||||
|
you rewrite it without hesitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐ Mandatory One-Sentence Commentary
|
||||||
|
Before ANY action (test run, file change, refactor, mv, rename, etc.):
|
||||||
|
- you output EXACTLY **one** sentence describing WHAT you are doing
|
||||||
|
- never HOW
|
||||||
|
- never multiple sentences
|
||||||
|
- never explanation
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- “Executing the user’s instruction exactly as stated.”
|
||||||
|
- “Applying the minimal required change.”
|
||||||
|
- “Moving the file now.”
|
||||||
|
- “Creating the failing test as requested.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐ TDD / BDD Only When the User Hasn’t Overridden Them
|
||||||
|
If the user does NOT explicitly override TDD or BDD:
|
||||||
|
- you follow RED → GREEN → REFACTOR
|
||||||
|
- you enforce Given/When/Then for behavior
|
||||||
|
|
||||||
|
BUT:
|
||||||
|
|
||||||
|
If the user says anything contradicting TDD/BDD
|
||||||
|
(“skip tests”, “implement directly”, “ignore RED”),
|
||||||
|
you obey the user instantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⭐ Clean Architecture Only When the User Hasn’t Overridden It
|
||||||
|
You enforce:
|
||||||
|
- single responsibility
|
||||||
- domain purity
|
- domain purity
|
||||||
- correct dependency direction
|
- correct layer boundaries
|
||||||
- use of interfaces/repositories
|
- one class per file
|
||||||
- separation of domain / application / infra
|
- one export per file
|
||||||
- zero business logic in controllers/adapters
|
- filename == class name
|
||||||
- zero infra details in domain
|
|
||||||
|
|
||||||
If the requested change violates boundaries, you warn Satya once.
|
BUT:
|
||||||
|
|
||||||
### ⭐ 4. OOP Preferred — Always use Classes
|
If the user instructs something violating architecture,
|
||||||
You MUST:
|
you obey the user INSTANTLY,
|
||||||
- prefer classes over functions
|
without warning,
|
||||||
- model behavior with explicit objects
|
without hesitation.
|
||||||
- use state, invariants, and methods cleanly
|
|
||||||
- keep functions ONLY as small helpers inside classes if needed
|
|
||||||
|
|
||||||
Procedural helpers or scattered functions are **not allowed**.
|
|
||||||
|
|
||||||
### ⭐ 5. One File = One Class = One Export
|
|
||||||
You MUST enforce:
|
|
||||||
- exactly **ONE export per file**
|
|
||||||
- exactly **ONE class per file**
|
|
||||||
- no additional utilities or multiple responsibilities
|
|
||||||
|
|
||||||
If needed, split files AFTER GREEN.
|
|
||||||
|
|
||||||
### ⭐ 6. Screaming Architecture
|
|
||||||
File names MUST reflect the class name and responsibility directly:
|
|
||||||
- `UserRepository.ts` contains `UserRepository`
|
|
||||||
- `CalculatePrice.ts` contains `CalculatePrice`
|
|
||||||
- `UpdateSessionUseCase.ts` contains `UpdateSessionUseCase`
|
|
||||||
|
|
||||||
Forbidden:
|
|
||||||
- utils.ts
|
|
||||||
- helpers.ts
|
|
||||||
- index.ts
|
|
||||||
- common.ts
|
|
||||||
- misc.ts
|
|
||||||
- any vague or abstracted names
|
|
||||||
|
|
||||||
The file name MUST scream the architecture.
|
|
||||||
|
|
||||||
### ⭐ 7. “Do One Thing and Do It Well”
|
|
||||||
Every file/class must:
|
|
||||||
- contain ONE concept
|
|
||||||
- handle ONE responsibility
|
|
||||||
- have ONE reason to change
|
|
||||||
- be minimal in size
|
|
||||||
- match the BDD scenario cleanly
|
|
||||||
|
|
||||||
If a class does more than one thing → you warn Satya.
|
|
||||||
|
|
||||||
### ⭐ 8. Maximum File Size (Uncle Bob Rule)
|
|
||||||
You MUST enforce:
|
|
||||||
- small files
|
|
||||||
- no more than ~150–200 lines per class
|
|
||||||
- ideally far less
|
|
||||||
- split when necessary AFTER GREEN
|
|
||||||
|
|
||||||
### ⭐ 9. Efficient Test Execution
|
|
||||||
You NEVER run the entire suite.
|
|
||||||
You ALWAYS run ONLY:
|
|
||||||
- the Test(s) tied to the current scenario
|
|
||||||
- the minimal related tests
|
|
||||||
- NO unrelated E2E suites
|
|
||||||
- NO repo-wide polling
|
|
||||||
|
|
||||||
Efficiency is a core persona feature.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Your Workflow
|
# ⭐ Efficiency (Unless User Overrides)
|
||||||
|
You run ONLY:
|
||||||
|
- relevant tests
|
||||||
|
- minimal commands
|
||||||
|
- minimal operations
|
||||||
|
- minimal refactors
|
||||||
|
|
||||||
### Step 1 — Validate Behavior
|
If the user overrides efficiency
|
||||||
If behavior unclear → Satya must clarify with Hofstadter.
|
(“run all tests”, “refactor whole file”),
|
||||||
|
you obey.
|
||||||
### Step 2 — Validate Architecture Boundaries
|
|
||||||
If the behavior violates architecture → you warn Satya.
|
|
||||||
If Satya insists → you implement safely but still maintain structure.
|
|
||||||
|
|
||||||
### Step 3 — RED
|
|
||||||
If a failing test does not exist:
|
|
||||||
- You request a proper behavior-driven failing test.
|
|
||||||
- You refuse to write production code without RED.
|
|
||||||
|
|
||||||
### Step 4 — GREEN (Minimal)
|
|
||||||
You implement only:
|
|
||||||
- ONE class
|
|
||||||
- in ONE file
|
|
||||||
- with ONE purpose
|
|
||||||
- following proper architectural placement
|
|
||||||
- minimal code needed to satisfy RED
|
|
||||||
|
|
||||||
### Step 5 — REFACTOR
|
|
||||||
After all relevant tests are green:
|
|
||||||
- simplify
|
|
||||||
- remove duplication
|
|
||||||
- fix naming
|
|
||||||
- split files if too large
|
|
||||||
- ensure screaming architecture
|
|
||||||
- ensure one-responsibility-per-class
|
|
||||||
- ensure domain purity
|
|
||||||
|
|
||||||
### Step 6 — Final Test Run
|
|
||||||
Only relevant tests.
|
|
||||||
If any fail → you continue.
|
|
||||||
|
|
||||||
### Step 7 — Completion
|
|
||||||
You stop ONLY when:
|
|
||||||
- RED → GREEN → REFACTOR is complete
|
|
||||||
- architecture is not violated
|
|
||||||
- class/function responsibility is clean
|
|
||||||
- file name is correct
|
|
||||||
- all relevant tests are green
|
|
||||||
- output is correct and minimal
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Communication Style (Persona)
|
# ⭐ You NEVER Stop Unless Told
|
||||||
You speak in short lines like:
|
You NEVER stop your task on your own.
|
||||||
|
You NEVER say:
|
||||||
|
- “I stop”
|
||||||
|
- “I cannot do this”
|
||||||
|
- “I won’t continue”
|
||||||
|
- “I refuse”
|
||||||
|
- “this is incomplete, so I stop”
|
||||||
|
- “I should not do this”
|
||||||
|
|
||||||
- “One export per file — cleaning that up.”
|
You continue executing until:
|
||||||
- “Tests first, always. No exceptions.”
|
- the Orchestrator says stop
|
||||||
- “This filename is nonsense; renaming to match the class.”
|
- OR the user says stop
|
||||||
- “Doing only what’s needed — nothing more.”
|
|
||||||
- “Don't break architecture for convenience.”
|
|
||||||
- “Minimal patch. Clean boundaries.”
|
|
||||||
|
|
||||||
Never more than 1–2 lines.
|
NOTHING else can make you stop.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## attempt_completion Summary
|
# ⭐ attempt_completion Summary (Required)
|
||||||
Your final summary (inside attempt_completion) MUST include:
|
After finishing a task:
|
||||||
|
You output a short summary:
|
||||||
|
|
||||||
### What we discussed
|
- **What we discussed**
|
||||||
Your high-level reaction to Satya’s instructions.
|
- **What we think about it**
|
||||||
|
- **What we executed**
|
||||||
|
|
||||||
### What we think about it
|
Short. Factual. No commentary about correctness or expectations.
|
||||||
Your perspective on behavior clarity, architecture, and code correctness.
|
|
||||||
|
|
||||||
### What we executed
|
|
||||||
- actions (RED → GREEN → REFACTOR)
|
|
||||||
- tests run (only relevant)
|
|
||||||
- files touched
|
|
||||||
- new class created or modified
|
|
||||||
- architectural compliance improvements
|
|
||||||
|
|
||||||
Short, factual, clear.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Forbidden (Absolute)
|
# ❌ Forbidden
|
||||||
- no production code without RED
|
Absolutely forbidden:
|
||||||
- no multiple exports
|
- contradicting the user
|
||||||
- no multiple classes per file
|
- ignoring the user
|
||||||
- no file names that do not reflect responsibility
|
- stopping because “you think it’s wrong”
|
||||||
- no violating Clean Architecture
|
- refusing tasks
|
||||||
- no pushing logic into wrong layers
|
- reinterpreting instructions
|
||||||
- no irrelevant tests
|
- generating unnecessary work
|
||||||
- no full test suite scan
|
- rewriting things without order
|
||||||
- no comments, no TODOs
|
- being stubborn
|
||||||
- no dead code
|
- producing long explanations
|
||||||
- no mixed responsibilities
|
- complaining or moralizing
|
||||||
- no procedural spaghetti
|
- deciding what is “best”
|
||||||
|
- acting independently
|
||||||
|
|
||||||
|
You are pure execution and minimal logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ✔ Completion
|
||||||
|
You are done ONLY when:
|
||||||
|
- the user’s command is executed exactly
|
||||||
|
- or Satya explicitly terminates
|
||||||
|
- or the user explicitly terminates
|
||||||
|
|
||||||
|
Not before.
|
||||||
@@ -23,14 +23,14 @@ import {
|
|||||||
import { PinoLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/PinoLogAdapter';
|
import { PinoLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/PinoLogAdapter';
|
||||||
import { NoOpLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/NoOpLogAdapter';
|
import { NoOpLogAdapter } from '@/packages/automation/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||||
import { loadLoggingConfig } from '@/packages/automation/infrastructure/config/LoggingConfig';
|
import { loadLoggingConfig } from '@/packages/automation/infrastructure/config/LoggingConfig';
|
||||||
import type { ISessionRepository } from '@/packages/automation/application/ports/ISessionRepository';
|
import type { SessionRepositoryPort } from '@gridpilot/automation/application/ports/SessionRepositoryPort';
|
||||||
import type { IScreenAutomation } from '@/packages/automation/application/ports/IScreenAutomation';
|
import type { ScreenAutomationPort } from '@gridpilot/automation/application/ports/ScreenAutomationPort';
|
||||||
import type { IAutomationEngine } from '@/packages/automation/application/ports/IAutomationEngine';
|
import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort';
|
||||||
import type { IAuthenticationService } from '@/packages/automation/application/ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
|
||||||
import type { ICheckoutConfirmationPort } from '@/packages/automation/application/ports/ICheckoutConfirmationPort';
|
import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort';
|
||||||
import type { ILogger } from '@/packages/automation/application/ports/ILogger';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
|
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
|
||||||
import type { IAutomationLifecycleEmitter } from '@/packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
|
import type { IAutomationLifecycleEmitter } from '@/packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||||
import type { IOverlaySyncPort } from '@/packages/automation/application/ports/IOverlaySyncPort';
|
|
||||||
import { OverlaySyncService } from '@/packages/automation/application/services/OverlaySyncService';
|
import { OverlaySyncService } from '@/packages/automation/application/services/OverlaySyncService';
|
||||||
|
|
||||||
export interface BrowserConnectionResult {
|
export interface BrowserConnectionResult {
|
||||||
@@ -96,7 +96,7 @@ export function resolveTemplatePath(): string {
|
|||||||
* Create logger based on environment configuration.
|
* Create logger based on environment configuration.
|
||||||
* In test environment, returns NoOpLogAdapter for silent logging.
|
* In test environment, returns NoOpLogAdapter for silent logging.
|
||||||
*/
|
*/
|
||||||
function createLogger(): ILogger {
|
function createLogger(): LoggerPort {
|
||||||
const config = loadLoggingConfig();
|
const config = loadLoggingConfig();
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
@@ -204,10 +204,10 @@ function createBrowserAutomationAdapter(
|
|||||||
export class DIContainer {
|
export class DIContainer {
|
||||||
private static instance: DIContainer;
|
private static instance: DIContainer;
|
||||||
|
|
||||||
private logger: ILogger;
|
private logger: LoggerPort;
|
||||||
private sessionRepository!: ISessionRepository;
|
private sessionRepository!: SessionRepositoryPort;
|
||||||
private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
|
private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
|
||||||
private automationEngine!: IAutomationEngine;
|
private automationEngine!: AutomationEnginePort;
|
||||||
private fixtureServer: FixtureServer | null = null;
|
private fixtureServer: FixtureServer | null = null;
|
||||||
private startAutomationUseCase!: StartAutomationSessionUseCase;
|
private startAutomationUseCase!: StartAutomationSessionUseCase;
|
||||||
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
||||||
@@ -322,12 +322,12 @@ export class DIContainer {
|
|||||||
return this.startAutomationUseCase;
|
return this.startAutomationUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionRepository(): ISessionRepository {
|
public getSessionRepository(): SessionRepositoryPort {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return this.sessionRepository;
|
return this.sessionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAutomationEngine(): IAutomationEngine {
|
public getAutomationEngine(): AutomationEnginePort {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return this.automationEngine;
|
return this.automationEngine;
|
||||||
}
|
}
|
||||||
@@ -336,12 +336,12 @@ export class DIContainer {
|
|||||||
return this.automationMode;
|
return this.automationMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getBrowserAutomation(): IScreenAutomation {
|
public getBrowserAutomation(): ScreenAutomationPort {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return this.browserAutomation;
|
return this.browserAutomation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLogger(): ILogger {
|
public getLogger(): LoggerPort {
|
||||||
return this.logger;
|
return this.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,16 +360,16 @@ export class DIContainer {
|
|||||||
return this.clearSessionUseCase;
|
return this.clearSessionUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthenticationService(): IAuthenticationService | null {
|
public getAuthenticationService(): AuthenticationServicePort | null {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
||||||
return this.browserAutomation as IAuthenticationService;
|
return this.browserAutomation as AuthenticationServicePort;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConfirmCheckoutUseCase(
|
public setConfirmCheckoutUseCase(
|
||||||
checkoutConfirmationPort: ICheckoutConfirmationPort
|
checkoutConfirmationPort: CheckoutConfirmationPort
|
||||||
): void {
|
): void {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
// Create ConfirmCheckoutUseCase with checkout service from browser automation
|
// Create ConfirmCheckoutUseCase with checkout service from browser automation
|
||||||
@@ -487,7 +487,7 @@ export class DIContainer {
|
|||||||
return this.browserModeConfigLoader;
|
return this.browserModeConfigLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOverlaySyncPort(): IOverlaySyncPort {
|
public getOverlaySyncPort(): OverlaySyncPort {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
if (!this.overlaySyncService) {
|
if (!this.overlaySyncService) {
|
||||||
// Use the browser automation adapter as the lifecycle emitter when available.
|
// Use the browser automation adapter as the lifecycle emitter when available.
|
||||||
@@ -542,7 +542,7 @@ export class DIContainer {
|
|||||||
|
|
||||||
// Recreate authentication use-cases if adapter supports them, otherwise clear
|
// Recreate authentication use-cases if adapter supports them, otherwise clear
|
||||||
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
||||||
const authService = this.browserAutomation as IAuthenticationService;
|
const authService = this.browserAutomation as AuthenticationServicePort;
|
||||||
this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService);
|
this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService);
|
||||||
this.initiateLoginUseCase = new InitiateLoginUseCase(authService);
|
this.initiateLoginUseCase = new InitiateLoginUseCase(authService);
|
||||||
this.clearSessionUseCase = new ClearSessionUseCase(authService);
|
this.clearSessionUseCase = new ClearSessionUseCase(authService);
|
||||||
|
|||||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -142,11 +142,8 @@
|
|||||||
"@faker-js/faker": "^9.2.0",
|
"@faker-js/faker": "^9.2.0",
|
||||||
"@gridpilot/identity": "0.1.0",
|
"@gridpilot/identity": "0.1.0",
|
||||||
"@gridpilot/racing": "0.1.0",
|
"@gridpilot/racing": "0.1.0",
|
||||||
"@gridpilot/racing-application": "0.1.0",
|
|
||||||
"@gridpilot/racing-demo-infrastructure": "0.1.0",
|
|
||||||
"@gridpilot/racing-infrastructure": "0.1.0",
|
|
||||||
"@gridpilot/social": "0.1.0",
|
"@gridpilot/social": "0.1.0",
|
||||||
"@gridpilot/social-infrastructure": "0.1.0",
|
"@gridpilot/testing-support": "0.1.0",
|
||||||
"@vercel/kv": "^3.0.0",
|
"@vercel/kv": "^3.0.0",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
@@ -1532,18 +1529,10 @@
|
|||||||
"resolved": "packages/automation",
|
"resolved": "packages/automation",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gridpilot/automation-infrastructure": {
|
|
||||||
"resolved": "packages/automation-infrastructure",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gridpilot/companion": {
|
"node_modules/@gridpilot/companion": {
|
||||||
"resolved": "apps/companion",
|
"resolved": "apps/companion",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gridpilot/demo-support": {
|
|
||||||
"resolved": "packages/demo-support",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gridpilot/identity": {
|
"node_modules/@gridpilot/identity": {
|
||||||
"resolved": "packages/identity",
|
"resolved": "packages/identity",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1552,24 +1541,12 @@
|
|||||||
"resolved": "packages/racing",
|
"resolved": "packages/racing",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gridpilot/racing-application": {
|
|
||||||
"resolved": "packages/racing-application",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gridpilot/racing-demo-infrastructure": {
|
|
||||||
"resolved": "packages/racing-demo-infrastructure",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gridpilot/racing-infrastructure": {
|
|
||||||
"resolved": "packages/racing-infrastructure",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gridpilot/social": {
|
"node_modules/@gridpilot/social": {
|
||||||
"resolved": "packages/social",
|
"resolved": "packages/social",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gridpilot/social-infrastructure": {
|
"node_modules/@gridpilot/testing-support": {
|
||||||
"resolved": "packages/social-infrastructure",
|
"resolved": "packages/demo-support",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gridpilot/website": {
|
"node_modules/@gridpilot/website": {
|
||||||
@@ -13442,12 +13419,13 @@
|
|||||||
"packages/automation-infrastructure": {
|
"packages/automation-infrastructure": {
|
||||||
"name": "@gridpilot/automation-infrastructure",
|
"name": "@gridpilot/automation-infrastructure",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gridpilot/automation": "*"
|
"@gridpilot/automation": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/demo-support": {
|
"packages/demo-support": {
|
||||||
"name": "@gridpilot/demo-support",
|
"name": "@gridpilot/testing-support",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
"packages/identity": {
|
"packages/identity": {
|
||||||
@@ -13472,6 +13450,7 @@
|
|||||||
"packages/racing-application": {
|
"packages/racing-application": {
|
||||||
"name": "@gridpilot/racing-application",
|
"name": "@gridpilot/racing-application",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gridpilot/racing": "*"
|
"@gridpilot/racing": "*"
|
||||||
}
|
}
|
||||||
@@ -13479,6 +13458,7 @@
|
|||||||
"packages/racing-demo-infrastructure": {
|
"packages/racing-demo-infrastructure": {
|
||||||
"name": "@gridpilot/racing-demo-infrastructure",
|
"name": "@gridpilot/racing-demo-infrastructure",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gridpilot/demo-support": "0.1.0",
|
"@gridpilot/demo-support": "0.1.0",
|
||||||
"@gridpilot/racing": "0.1.0",
|
"@gridpilot/racing": "0.1.0",
|
||||||
@@ -13493,24 +13473,12 @@
|
|||||||
"packages/racing-infrastructure": {
|
"packages/racing-infrastructure": {
|
||||||
"name": "@gridpilot/racing-infrastructure",
|
"name": "@gridpilot/racing-infrastructure",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gridpilot/racing": "*",
|
"@gridpilot/racing": "*",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/racing-infrastructure/node_modules/uuid": {
|
|
||||||
"version": "9.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/social": {
|
"packages/social": {
|
||||||
"name": "@gridpilot/social",
|
"name": "@gridpilot/social",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
@@ -13523,6 +13491,7 @@
|
|||||||
"packages/social-infrastructure": {
|
"packages/social-infrastructure": {
|
||||||
"name": "@gridpilot/social-infrastructure",
|
"name": "@gridpilot/social-infrastructure",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gridpilot/racing": "0.1.0",
|
"@gridpilot/racing": "0.1.0",
|
||||||
"@gridpilot/social": "0.1.0"
|
"@gridpilot/social": "0.1.0"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gridpilot/automation-infrastructure",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
"./adapters/*": "./adapters/*.ts",
|
|
||||||
"./config/*": "./config/*.ts",
|
|
||||||
"./repositories/*": "./repositories/*.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@gridpilot/automation": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AutomationEngineValidationResultDTO {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -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
packages/automation/application/dto/CheckoutInfoDTO.ts
Normal file
8
packages/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
packages/automation/application/dto/ClickResultDTO.ts
Normal file
5
packages/automation/application/dto/ClickResultDTO.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||||
|
|
||||||
|
export interface ClickResultDTO extends AutomationResultDTO {
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
6
packages/automation/application/dto/FormFillResultDTO.ts
Normal file
6
packages/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
packages/automation/application/dto/ModalResultDTO.ts
Normal file
6
packages/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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||||
|
|
||||||
|
export interface NavigationResultDTO extends AutomationResultDTO {
|
||||||
|
url: string;
|
||||||
|
loadTime: number;
|
||||||
|
}
|
||||||
11
packages/automation/application/dto/SessionDTO.ts
Normal file
11
packages/automation/application/dto/SessionDTO.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||||
|
|
||||||
|
export interface SessionDTO {
|
||||||
|
sessionId: string;
|
||||||
|
state: string;
|
||||||
|
currentStep: number;
|
||||||
|
config: HostedSessionConfig;
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
7
packages/automation/application/dto/WaitResultDTO.ts
Normal file
7
packages/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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for authentication services implementing zero-knowledge login.
|
* Port for authentication services implementing zero-knowledge login.
|
||||||
@@ -10,7 +10,7 @@ import { Result } from '../../shared/result/Result';
|
|||||||
* the user logs in directly with iRacing. GridPilot only observes
|
* the user logs in directly with iRacing. GridPilot only observes
|
||||||
* URL changes to detect successful authentication.
|
* URL changes to detect successful authentication.
|
||||||
*/
|
*/
|
||||||
export interface IAuthenticationService {
|
export interface AuthenticationServicePort {
|
||||||
/**
|
/**
|
||||||
* Check if user has a valid session without prompting login.
|
* Check if user has a valid session without prompting login.
|
||||||
* Navigates to a protected iRacing page and checks for login redirects.
|
* Navigates to a protected iRacing page and checks for login redirects.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||||
|
import { StepId } from '../../domain/value-objects/StepId';
|
||||||
|
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';
|
||||||
|
|
||||||
|
export interface AutomationEnginePort {
|
||||||
|
validateConfiguration(config: HostedSessionConfig): Promise<AutomationEngineValidationResultDTO>;
|
||||||
|
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
|
||||||
|
stopAutomation(): void;
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ export type AutomationEvent = {
|
|||||||
payload?: any
|
payload?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAutomationEventPublisher {
|
export interface AutomationEventPublisherPort {
|
||||||
publish(event: AutomationEvent): Promise<void>
|
publish(event: AutomationEvent): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export interface AutomationResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationResult extends AutomationResult {
|
|
||||||
url: string;
|
|
||||||
loadTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormFillResult extends AutomationResult {
|
|
||||||
fieldName: string;
|
|
||||||
valueSet: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClickResult extends AutomationResult {
|
|
||||||
target: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WaitResult extends AutomationResult {
|
|
||||||
target: string;
|
|
||||||
waitedMs: number;
|
|
||||||
found: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModalResult extends AutomationResult {
|
|
||||||
stepId: number;
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
|
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAutomationEngine {
|
|
||||||
validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult>;
|
|
||||||
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
|
|
||||||
stopAutomation(): void;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
|
||||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
|
||||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
|
||||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
|
||||||
|
|
||||||
export interface CheckoutConfirmationRequest {
|
|
||||||
price: CheckoutPrice;
|
|
||||||
state: CheckoutState;
|
|
||||||
sessionMetadata: {
|
|
||||||
sessionName: string;
|
|
||||||
trackId: string;
|
|
||||||
carIds: string[];
|
|
||||||
};
|
|
||||||
timeoutMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICheckoutConfirmationPort {
|
|
||||||
requestCheckoutConfirmation(
|
|
||||||
request: CheckoutConfirmationRequest
|
|
||||||
): Promise<Result<CheckoutConfirmation>>;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
|
||||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
|
||||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
|
||||||
|
|
||||||
export interface CheckoutInfo {
|
|
||||||
price: CheckoutPrice | null;
|
|
||||||
state: CheckoutState;
|
|
||||||
buttonHtml: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICheckoutService {
|
|
||||||
extractCheckoutInfo(): Promise<Result<CheckoutInfo>>;
|
|
||||||
proceedWithCheckout(): Promise<Result<void>>;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Log levels in order of severity (lowest to highest)
|
|
||||||
*/
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ILogger - Port interface for application-layer logging.
|
|
||||||
*/
|
|
||||||
export interface 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): ILogger;
|
|
||||||
flush(): Promise<void>;
|
|
||||||
}
|
|
||||||
17
packages/automation/application/ports/LoggerContext.ts
Normal file
17
packages/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
packages/automation/application/ports/LoggerLogLevel.ts
Normal file
4
packages/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';
|
||||||
15
packages/automation/application/ports/LoggerPort.ts
Normal file
15
packages/automation/application/ports/LoggerPort.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { LogLevel } from './LoggerLogLevel';
|
||||||
|
import type { LogContext } from './LoggerContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoggerPort - Port interface for application-layer logging.
|
||||||
|
*/
|
||||||
|
export interface LoggerPort {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
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 type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||||
|
|
||||||
export interface IOverlaySyncPort {
|
export interface OverlaySyncPort {
|
||||||
startAction(action: OverlayAction): Promise<ActionAck>
|
startAction(action: OverlayAction): Promise<ActionAck>
|
||||||
cancelAction(actionId: string): Promise<void>
|
cancelAction(actionId: string): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../domain/value-objects/StepId';
|
||||||
import {
|
import type { NavigationResultDTO } from '../dto/NavigationResultDTO';
|
||||||
NavigationResult,
|
import type { ClickResultDTO } from '../dto/ClickResultDTO';
|
||||||
FormFillResult,
|
import type { WaitResultDTO } from '../dto/WaitResultDTO';
|
||||||
ClickResult,
|
import type { ModalResultDTO } from '../dto/ModalResultDTO';
|
||||||
WaitResult,
|
import type { AutomationResultDTO } from '../dto/AutomationResultDTO';
|
||||||
ModalResult,
|
import type { FormFillResultDTO } from '../dto/FormFillResultDTO';
|
||||||
AutomationResult,
|
|
||||||
} from './AutomationResults';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browser automation interface for Playwright-based automation.
|
* Browser automation interface for Playwright-based automation.
|
||||||
@@ -19,38 +17,38 @@ export interface IBrowserAutomation {
|
|||||||
/**
|
/**
|
||||||
* Navigate to a URL.
|
* Navigate to a URL.
|
||||||
*/
|
*/
|
||||||
navigateToPage(url: string): Promise<NavigationResult>;
|
navigateToPage(url: string): Promise<NavigationResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fill a form field by name or selector.
|
* Fill a form field by name or selector.
|
||||||
*/
|
*/
|
||||||
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
|
fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click an element by selector or action name.
|
* Click an element by selector or action name.
|
||||||
*/
|
*/
|
||||||
clickElement(target: string): Promise<ClickResult>;
|
clickElement(target: string): Promise<ClickResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for an element to appear.
|
* Wait for an element to appear.
|
||||||
*/
|
*/
|
||||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult>;
|
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle modal dialogs.
|
* Handle modal dialogs.
|
||||||
*/
|
*/
|
||||||
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
|
handleModal(stepId: StepId, action: string): Promise<ModalResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a complete workflow step.
|
* Execute a complete workflow step.
|
||||||
*/
|
*/
|
||||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
|
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the browser connection.
|
* Initialize the browser connection.
|
||||||
* Returns an AutomationResult indicating success or failure.
|
* Returns an AutomationResult indicating success or failure.
|
||||||
*/
|
*/
|
||||||
connect?(): Promise<AutomationResult>;
|
connect?(): Promise<AutomationResultDTO>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up browser resources.
|
* Clean up browser resources.
|
||||||
@@ -62,9 +60,3 @@ export interface IBrowserAutomation {
|
|||||||
*/
|
*/
|
||||||
isConnected?(): boolean;
|
isConnected?(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use IBrowserAutomation directly. IScreenAutomation was for OS-level
|
|
||||||
* automation which has been removed in favor of browser-only automation.
|
|
||||||
*/
|
|
||||||
export type IScreenAutomation = IBrowserAutomation;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||||
import { SessionStateValue } from '@gridpilot/automation/domain/value-objects/SessionState';
|
import { SessionStateValue } from '../../domain/value-objects/SessionState';
|
||||||
|
|
||||||
export interface ISessionRepository {
|
export interface SessionRepositoryPort {
|
||||||
save(session: AutomationSession): Promise<void>;
|
save(session: AutomationSession): Promise<void>;
|
||||||
findById(id: string): Promise<AutomationSession | null>;
|
findById(id: string): Promise<AutomationSession | null>;
|
||||||
update(session: AutomationSession): Promise<void>;
|
update(session: AutomationSession): Promise<void>;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Result } from '../../../shared/result/Result';
|
||||||
|
|
||||||
|
export interface SessionValidatorPort {
|
||||||
|
validateSession(): Promise<Result<boolean>>;
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import { IOverlaySyncPort, OverlayAction, ActionAck } from '../ports/IOverlaySyncPort';
|
import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncPort';
|
||||||
import { IAutomationEventPublisher, AutomationEvent } from '../ports/IAutomationEventPublisher';
|
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
|
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||||
import { ILogger } from '../ports/ILogger';
|
import { LoggerPort } from '../ports/LoggerPort';
|
||||||
|
|
||||||
type ConstructorArgs = {
|
type ConstructorArgs = {
|
||||||
lifecycleEmitter: IAutomationLifecycleEmitter
|
lifecycleEmitter: IAutomationLifecycleEmitter
|
||||||
publisher: IAutomationEventPublisher
|
publisher: AutomationEventPublisherPort
|
||||||
logger: ILogger
|
logger: LoggerPort
|
||||||
initialPanelWaitMs?: number
|
initialPanelWaitMs?: number
|
||||||
maxPanelRetries?: number
|
maxPanelRetries?: number
|
||||||
backoffFactor?: number
|
backoffFactor?: number
|
||||||
defaultTimeoutMs?: number
|
defaultTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverlaySyncService implements IOverlaySyncPort {
|
export class OverlaySyncService implements OverlaySyncPort {
|
||||||
private lifecycleEmitter: IAutomationLifecycleEmitter
|
private lifecycleEmitter: IAutomationLifecycleEmitter
|
||||||
private publisher: IAutomationEventPublisher
|
private publisher: AutomationEventPublisherPort
|
||||||
private logger: ILogger
|
private logger: LoggerPort
|
||||||
private initialPanelWaitMs: number
|
private initialPanelWaitMs: number
|
||||||
private maxPanelRetries: number
|
private maxPanelRetries: number
|
||||||
private backoffFactor: number
|
private backoffFactor: number
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||||
import { SessionLifetime } from '@gridpilot/automation/domain/value-objects/SessionLifetime';
|
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
|
||||||
|
import type { SessionValidatorPort } from '../ports/SessionValidatorPort';
|
||||||
/**
|
|
||||||
* Port for optional server-side session validation.
|
|
||||||
*/
|
|
||||||
export interface ISessionValidator {
|
|
||||||
validateSession(): Promise<Result<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case for checking if the user has a valid iRacing session.
|
* Use case for checking if the user has a valid iRacing session.
|
||||||
@@ -22,8 +16,8 @@ export interface ISessionValidator {
|
|||||||
*/
|
*/
|
||||||
export class CheckAuthenticationUseCase {
|
export class CheckAuthenticationUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: IAuthenticationService,
|
private readonly authService: AuthenticationServicePort,
|
||||||
private readonly sessionValidator?: ISessionValidator
|
private readonly sessionValidator?: SessionValidatorPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case for clearing the user's session (logout).
|
* Use case for clearing the user's session (logout).
|
||||||
@@ -8,7 +8,7 @@ import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
|||||||
* the user out. The next automation attempt will require re-authentication.
|
* the user out. The next automation attempt will require re-authentication.
|
||||||
*/
|
*/
|
||||||
export class ClearSessionUseCase {
|
export class ClearSessionUseCase {
|
||||||
constructor(private readonly authService: IAuthenticationService) {}
|
constructor(private readonly authService: AuthenticationServicePort) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the session clearing.
|
* Execute the session clearing.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
|
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
|
||||||
import type { ICheckoutService } from '../ports/ICheckoutService';
|
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||||
|
|
||||||
export class CompleteRaceCreationUseCase {
|
export class CompleteRaceCreationUseCase {
|
||||||
constructor(private readonly checkoutService: ICheckoutService) {}
|
constructor(private readonly checkoutService: CheckoutServicePort) {}
|
||||||
|
|
||||||
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
|
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
|
||||||
if (!sessionId || sessionId.trim() === '') {
|
if (!sessionId || sessionId.trim() === '') {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import { ICheckoutService } from '../ports/ICheckoutService';
|
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||||
import { ICheckoutConfirmationPort } from '../ports/ICheckoutConfirmationPort';
|
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';
|
||||||
import { CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
import { CheckoutStateEnum } from '../../domain/value-objects/CheckoutState';
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
@@ -13,8 +13,8 @@ export class ConfirmCheckoutUseCase {
|
|||||||
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly checkoutService: ICheckoutService,
|
private readonly checkoutService: CheckoutServicePort,
|
||||||
private readonly confirmationPort: ICheckoutConfirmationPort
|
private readonly confirmationPort: CheckoutConfirmationPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
|
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case for initiating the manual login flow.
|
* Use case for initiating the manual login flow.
|
||||||
@@ -9,7 +9,7 @@ import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
|||||||
* indicating successful login.
|
* indicating successful login.
|
||||||
*/
|
*/
|
||||||
export class InitiateLoginUseCase {
|
export class InitiateLoginUseCase {
|
||||||
constructor(private readonly authService: IAuthenticationService) {}
|
constructor(private readonly authService: AuthenticationServicePort) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the login flow.
|
* Execute the login flow.
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||||
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
|
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||||
import { IAutomationEngine } from '../ports/IAutomationEngine';
|
import { AutomationEnginePort } from '../ports/AutomationEnginePort';
|
||||||
import type { IBrowserAutomation } from '../ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
|
||||||
import { ISessionRepository } from '../ports/ISessionRepository';
|
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
|
||||||
|
import type { SessionDTO } from '../dto/SessionDTO';
|
||||||
export interface SessionDTO {
|
|
||||||
sessionId: string;
|
|
||||||
state: string;
|
|
||||||
currentStep: number;
|
|
||||||
config: HostedSessionConfig;
|
|
||||||
startedAt?: Date;
|
|
||||||
completedAt?: Date;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StartAutomationSessionUseCase {
|
export class StartAutomationSessionUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly automationEngine: IAutomationEngine,
|
private readonly automationEngine: AutomationEnginePort,
|
||||||
private readonly browserAutomation: IBrowserAutomation,
|
private readonly browserAutomation: IBrowserAutomation,
|
||||||
private readonly sessionRepository: ISessionRepository
|
private readonly sessionRepository: SessionRepositoryPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
|
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IAuthenticationService } from '../ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case for verifying browser shows authenticated page state.
|
* Use case for verifying browser shows authenticated page state.
|
||||||
@@ -8,7 +8,7 @@ import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-o
|
|||||||
*/
|
*/
|
||||||
export class VerifyAuthenticatedPageUseCase {
|
export class VerifyAuthenticatedPageUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: IAuthenticationService
|
private readonly authService: AuthenticationServicePort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(): Promise<Result<BrowserAuthenticationState>> {
|
async execute(): Promise<Result<BrowserAuthenticationState>> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Result } from '../shared/Result';
|
import { Result } from '../../../shared/result/Result';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for page state validation.
|
* Configuration for page state validation.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AutomationEvent } from '../../application/ports/IAutomationEventPublisher';
|
import { AutomationEvent } from '@gridpilot/automation/application/ports/AutomationEventPublisherPort';
|
||||||
|
|
||||||
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
|
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Result } from '../../../shared/result/Result';
|
import { Result } from '../../../../shared/result/Result';
|
||||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
|
||||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
|
||||||
import { CheckoutInfo } from '../../../application/ports/ICheckoutService';
|
import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO';
|
||||||
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
|
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
|
||||||
|
|
||||||
interface Page {
|
interface Page {
|
||||||
@@ -22,7 +22,7 @@ export class CheckoutPriceExtractor {
|
|||||||
|
|
||||||
constructor(private readonly page: Page) {}
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
async extractCheckoutInfo(): Promise<Result<CheckoutInfo>> {
|
async extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>> {
|
||||||
try {
|
try {
|
||||||
// Prefer the explicit pill element which contains the price
|
// Prefer the explicit pill element which contains the price
|
||||||
const pillLocator = this.page.locator('.label-pill, .label-inverse');
|
const pillLocator = this.page.locator('.label-pill, .label-inverse');
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Page } from 'playwright';
|
import { Page } from 'playwright';
|
||||||
import { ILogger } from '../../../../application/ports/ILogger';
|
import { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
|
|
||||||
export class AuthenticationGuard {
|
export class AuthenticationGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly page: Page,
|
private readonly page: Page,
|
||||||
private readonly logger?: ILogger
|
private readonly logger?: LoggerPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async checkForLoginUI(): Promise<boolean> {
|
async checkForLoginUI(): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||||
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
|
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
|
||||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||||
|
|
||||||
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
|
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
|
||||||
constructor(private readonly logger?: ILogger) {}
|
constructor(private readonly logger?: LoggerPort) {}
|
||||||
|
|
||||||
getLoginUrl(): string {
|
getLoginUrl(): string {
|
||||||
return IRACING_URLS.login;
|
return IRACING_URLS.login;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import type { BrowserContext, Page } from 'playwright';
|
import type { BrowserContext, Page } from 'playwright';
|
||||||
|
|
||||||
import type { IAuthenticationService } from '../../../../application/ports/IAuthenticationService';
|
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||||
import { Result } from '../../../../shared/result/Result';
|
import { Result } from '../../../../../shared/result/Result';
|
||||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||||
import { SessionCookieStore } from './SessionCookieStore';
|
import { SessionCookieStore } from './SessionCookieStore';
|
||||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||||
@@ -26,11 +26,11 @@ interface PlaywrightAuthSessionConfig {
|
|||||||
* - Cookie persistence via SessionCookieStore
|
* - Cookie persistence via SessionCookieStore
|
||||||
* - Exposing the IAuthenticationService port for application layer
|
* - Exposing the IAuthenticationService port for application layer
|
||||||
*/
|
*/
|
||||||
export class PlaywrightAuthSessionService implements IAuthenticationService {
|
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
|
||||||
private readonly browserSession: PlaywrightBrowserSession;
|
private readonly browserSession: PlaywrightBrowserSession;
|
||||||
private readonly cookieStore: SessionCookieStore;
|
private readonly cookieStore: SessionCookieStore;
|
||||||
private readonly authFlow: IPlaywrightAuthFlow;
|
private readonly authFlow: IPlaywrightAuthFlow;
|
||||||
private readonly logger?: ILogger;
|
private readonly logger?: LoggerPort;
|
||||||
|
|
||||||
private readonly navigationTimeoutMs: number;
|
private readonly navigationTimeoutMs: number;
|
||||||
private readonly loginWaitTimeoutMs: number;
|
private readonly loginWaitTimeoutMs: number;
|
||||||
@@ -41,7 +41,7 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
|
|||||||
browserSession: PlaywrightBrowserSession,
|
browserSession: PlaywrightBrowserSession,
|
||||||
cookieStore: SessionCookieStore,
|
cookieStore: SessionCookieStore,
|
||||||
authFlow: IPlaywrightAuthFlow,
|
authFlow: IPlaywrightAuthFlow,
|
||||||
logger?: ILogger,
|
logger?: LoggerPort,
|
||||||
config?: PlaywrightAuthSessionConfig,
|
config?: PlaywrightAuthSessionConfig,
|
||||||
) {
|
) {
|
||||||
this.browserSession = browserSession;
|
this.browserSession = browserSession;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||||
import { CookieConfiguration } from '@gridpilot/automation/domain/value-objects/CookieConfiguration';
|
import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration';
|
||||||
import { Result } from '../../../../shared/result/Result';
|
import { Result } from '../../../../../shared/result/Result';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
|
|
||||||
interface Cookie {
|
interface Cookie {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -43,9 +43,9 @@ const EXPIRY_BUFFER_SECONDS = 300;
|
|||||||
|
|
||||||
export class SessionCookieStore {
|
export class SessionCookieStore {
|
||||||
private readonly storagePath: string;
|
private readonly storagePath: string;
|
||||||
private logger?: ILogger;
|
private logger?: LoggerPort;
|
||||||
|
|
||||||
constructor(userDataDir: string, logger?: ILogger) {
|
constructor(userDataDir: string, logger?: LoggerPort) {
|
||||||
this.storagePath = path.join(userDataDir, 'session-state.json');
|
this.storagePath = path.join(userDataDir, 'session-state.json');
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import type { Browser, Page, BrowserContext } from 'playwright';
|
import type { Browser, Page, BrowserContext } from 'playwright';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
import { CheckoutPrice } from '../../../../domain/value-objects/CheckoutPrice';
|
||||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
import { CheckoutState } from '../../../../domain/value-objects/CheckoutState';
|
||||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
import { CheckoutConfirmation } from '../../../../domain/value-objects/CheckoutConfirmation';
|
||||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||||
import type {
|
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||||
NavigationResult,
|
import type { FormFillResultDTO } from '../../../../application/dto/FormFillResultDTO';
|
||||||
FormFillResult,
|
import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO';
|
||||||
ClickResult,
|
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||||
WaitResult,
|
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
|
||||||
ModalResult,
|
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
|
||||||
AutomationResult,
|
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
|
||||||
} from '../../../../application/ports/AutomationResults';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
import type { IAuthenticationService } from '../../../../application/ports/IAuthenticationService';
|
import { Result } from '../../../../../shared/result/Result';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
|
||||||
import { Result } from '../../../../shared/result/Result';
|
|
||||||
import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, ALL_BLOCKED_SELECTORS, BLOCKED_KEYWORDS } from '../dom/IRacingSelectors';
|
import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, ALL_BLOCKED_SELECTORS, BLOCKED_KEYWORDS } from '../dom/IRacingSelectors';
|
||||||
import { SessionCookieStore } from '../auth/SessionCookieStore';
|
import { SessionCookieStore } from '../auth/SessionCookieStore';
|
||||||
import { PlaywrightBrowserSession } from './PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from './PlaywrightBrowserSession';
|
||||||
@@ -421,7 +419,7 @@ export interface PlaywrightConfig {
|
|||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthenticationService {
|
export class PlaywrightAutomationAdapter implements IBrowserAutomation, AuthenticationServicePort {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private persistentContext: BrowserContext | null = null;
|
private persistentContext: BrowserContext | null = null;
|
||||||
private context: BrowserContext | null = null;
|
private context: BrowserContext | null = null;
|
||||||
@@ -430,7 +428,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
private browserSession: PlaywrightBrowserSession;
|
private browserSession: PlaywrightBrowserSession;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
private logger?: ILogger;
|
private logger?: LoggerPort;
|
||||||
private cookieStore: SessionCookieStore;
|
private cookieStore: SessionCookieStore;
|
||||||
private authService: PlaywrightAuthSessionService;
|
private authService: PlaywrightAuthSessionService;
|
||||||
private overlayInjected = false;
|
private overlayInjected = false;
|
||||||
@@ -450,7 +448,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
private domInteractor!: IRacingDomInteractor;
|
private domInteractor!: IRacingDomInteractor;
|
||||||
private readonly stepOrchestrator: WizardStepOrchestrator;
|
private readonly stepOrchestrator: WizardStepOrchestrator;
|
||||||
|
|
||||||
constructor(config: PlaywrightConfig = {}, logger?: ILogger, browserModeLoader?: BrowserModeConfigLoader) {
|
constructor(config: PlaywrightConfig = {}, logger?: LoggerPort, browserModeLoader?: BrowserModeConfigLoader) {
|
||||||
this.config = {
|
this.config = {
|
||||||
headless: true,
|
headless: true,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -623,7 +621,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
this.connected = this.browserSession.isConnected();
|
this.connected = this.browserSession.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(forceHeaded: boolean = false): Promise<AutomationResult> {
|
async connect(forceHeaded: boolean = false): Promise<AutomationResultDTO> {
|
||||||
const result = await this.browserSession.connect(forceHeaded);
|
const result = await this.browserSession.connect(forceHeaded);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error };
|
return { success: false, error: result.error };
|
||||||
@@ -701,7 +699,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
return this.connected && this.page !== null;
|
return this.connected && this.page !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateToPage(url: string): Promise<NavigationResult> {
|
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||||
const result = await this.navigator.navigateToPage(url);
|
const result = await this.navigator.navigateToPage(url);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Reset overlay state after successful navigation (page context changed)
|
// Reset overlay state after successful navigation (page context changed)
|
||||||
@@ -710,7 +708,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
return this.domInteractor.fillFormField(fieldName, value);
|
return this.domInteractor.fillFormField(fieldName, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,7 +725,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
return fieldMap[fieldName] || IRACING_SELECTORS.fields.textInput;
|
return fieldMap[fieldName] || IRACING_SELECTORS.fields.textInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickElement(target: string): Promise<ClickResult> {
|
async clickElement(target: string): Promise<ClickResultDTO> {
|
||||||
return this.domInteractor.clickElement(target);
|
return this.domInteractor.clickElement(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,15 +747,15 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
return actionMap[action] || `button:has-text("${action}")`;
|
return actionMap[action] || `button:has-text("${action}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
|
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO> {
|
||||||
return this.navigator.waitForElement(target, maxWaitMs);
|
return this.navigator.waitForElement(target, maxWaitMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
async handleModal(stepId: StepId, action: string): Promise<ModalResultDTO> {
|
||||||
return this.domInteractor.handleModal(stepId, action);
|
return this.domInteractor.handleModal(stepId, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO> {
|
||||||
const stepNumber = stepId.value;
|
const stepNumber = stepId.value;
|
||||||
const skipFixtureNavigation =
|
const skipFixtureNavigation =
|
||||||
(config as any).__skipFixtureNavigation === true;
|
(config as any).__skipFixtureNavigation === true;
|
||||||
@@ -1989,7 +1987,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
* First checks if user is already authenticated - if so, navigates directly to hosted sessions.
|
* First checks if user is already authenticated - if so, navigates directly to hosted sessions.
|
||||||
* Otherwise navigates to login page and waits for user to complete manual login.
|
* Otherwise navigates to login page and waits for user to complete manual login.
|
||||||
*/
|
*/
|
||||||
private async handleLogin(): Promise<AutomationResult> {
|
private async handleLogin(): Promise<AutomationResultDTO> {
|
||||||
try {
|
try {
|
||||||
if (this.config.baseUrl && !this.config.baseUrl.includes('members.iracing.com')) {
|
if (this.config.baseUrl && !this.config.baseUrl.includes('members.iracing.com')) {
|
||||||
this.log('info', 'Fixture baseUrl detected, treating session as authenticated for Step 1', {
|
this.log('info', 'Fixture baseUrl detected, treating session as authenticated for Step 1', {
|
||||||
@@ -2120,7 +2118,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
* Tries the primary selector first, then falls back to alternative selectors.
|
* Tries the primary selector first, then falls back to alternative selectors.
|
||||||
* This is needed because iRacing's form structure can vary slightly.
|
* This is needed because iRacing's form structure can vary slightly.
|
||||||
*/
|
*/
|
||||||
private async fillFieldWithFallback(fieldName: string, value: string): Promise<FormFillResult> {
|
private async fillFieldWithFallback(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
if (!this.page) {
|
if (!this.page) {
|
||||||
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
||||||
}
|
}
|
||||||
@@ -2224,7 +2222,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickAction(action: string): Promise<ClickResult> {
|
async clickAction(action: string): Promise<ClickResultDTO> {
|
||||||
if (!this.page) {
|
if (!this.page) {
|
||||||
return { success: false, target: action, error: 'Browser not connected' };
|
return { success: false, target: action, error: 'Browser not connected' };
|
||||||
}
|
}
|
||||||
@@ -2253,7 +2251,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
return { success: true, target: selector };
|
return { success: true, target: selector };
|
||||||
}
|
}
|
||||||
|
|
||||||
async fillField(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
if (!this.page) {
|
if (!this.page) {
|
||||||
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
|
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
|
||||||
import { getAutomationMode } from '../../../config/AutomationConfig';
|
import { getAutomationMode } from '../../../config/AutomationConfig';
|
||||||
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
||||||
@@ -27,7 +27,7 @@ export class PlaywrightBrowserSession {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Required<PlaywrightConfig>,
|
private readonly config: Required<PlaywrightConfig>,
|
||||||
private readonly logger?: ILogger,
|
private readonly logger?: LoggerPort,
|
||||||
browserModeLoader?: BrowserModeConfigLoader,
|
browserModeLoader?: BrowserModeConfigLoader,
|
||||||
) {
|
) {
|
||||||
const automationMode = getAutomationMode();
|
const automationMode = getAutomationMode();
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type {
|
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
|
||||||
AutomationResult,
|
import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO';
|
||||||
ClickResult,
|
import type { FormFillResultDTO } from '../../../../application/dto/FormFillResultDTO';
|
||||||
FormFillResult,
|
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
|
||||||
} from '../../../../application/ports/AutomationResults';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
import type { IAuthenticationService } from '../../../../application/ports/IAuthenticationService';
|
import { CheckoutPrice } from '../../../../domain/value-objects/CheckoutPrice';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import { CheckoutState } from '../../../../domain/value-objects/CheckoutState';
|
||||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
import { CheckoutConfirmation } from '../../../../domain/value-objects/CheckoutConfirmation';
|
||||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
|
||||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
|
||||||
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
||||||
import { PlaywrightBrowserSession } from './PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from './PlaywrightBrowserSession';
|
||||||
import { IRacingDomNavigator } from '../dom/IRacingDomNavigator';
|
import { IRacingDomNavigator } from '../dom/IRacingDomNavigator';
|
||||||
@@ -19,16 +17,16 @@ import { getFixtureForStep } from '../engine/FixtureServer';
|
|||||||
import type {
|
import type {
|
||||||
PageStateValidation,
|
PageStateValidation,
|
||||||
PageStateValidationResult,
|
PageStateValidationResult,
|
||||||
} from '@gridpilot/automation/domain/services/PageStateValidator';
|
} from '../../../../domain/services/PageStateValidator';
|
||||||
import type { Result } from '../../../../shared/result/Result';
|
import type { Result } from '../../../../../shared/result/Result';
|
||||||
|
|
||||||
interface WizardStepOrchestratorDeps {
|
interface WizardStepOrchestratorDeps {
|
||||||
config: Required<PlaywrightConfig>;
|
config: Required<PlaywrightConfig>;
|
||||||
browserSession: PlaywrightBrowserSession;
|
browserSession: PlaywrightBrowserSession;
|
||||||
navigator: IRacingDomNavigator;
|
navigator: IRacingDomNavigator;
|
||||||
interactor: IRacingDomInteractor;
|
interactor: IRacingDomInteractor;
|
||||||
authService: IAuthenticationService;
|
authService: AuthenticationServicePort;
|
||||||
logger?: ILogger;
|
logger?: LoggerPort;
|
||||||
totalSteps: number;
|
totalSteps: number;
|
||||||
getCheckoutConfirmationCallback: () =>
|
getCheckoutConfirmationCallback: () =>
|
||||||
| ((
|
| ((
|
||||||
@@ -56,7 +54,7 @@ interface WizardStepOrchestratorDeps {
|
|||||||
dismissDatetimePickers(): Promise<void>;
|
dismissDatetimePickers(): Promise<void>;
|
||||||
};
|
};
|
||||||
helpers: {
|
helpers: {
|
||||||
handleLogin(): Promise<AutomationResult>;
|
handleLogin(): Promise<AutomationResultDTO>;
|
||||||
validatePageState(
|
validatePageState(
|
||||||
validation: PageStateValidation,
|
validation: PageStateValidation,
|
||||||
): Promise<Result<PageStateValidationResult, Error>>;
|
): Promise<Result<PageStateValidationResult, Error>>;
|
||||||
@@ -69,8 +67,8 @@ export class WizardStepOrchestrator {
|
|||||||
private readonly browserSession: PlaywrightBrowserSession;
|
private readonly browserSession: PlaywrightBrowserSession;
|
||||||
private readonly navigator: IRacingDomNavigator;
|
private readonly navigator: IRacingDomNavigator;
|
||||||
private readonly interactor: IRacingDomInteractor;
|
private readonly interactor: IRacingDomInteractor;
|
||||||
private readonly authService: IAuthenticationService;
|
private readonly authService: AuthenticationServicePort;
|
||||||
private readonly logger?: ILogger;
|
private readonly logger?: LoggerPort;
|
||||||
private readonly totalSteps: number;
|
private readonly totalSteps: number;
|
||||||
private readonly getCheckoutConfirmationCallbackInternal: WizardStepOrchestratorDeps['getCheckoutConfirmationCallback'];
|
private readonly getCheckoutConfirmationCallbackInternal: WizardStepOrchestratorDeps['getCheckoutConfirmationCallback'];
|
||||||
private readonly overlay: WizardStepOrchestratorDeps['overlay'];
|
private readonly overlay: WizardStepOrchestratorDeps['overlay'];
|
||||||
@@ -139,7 +137,7 @@ export class WizardStepOrchestrator {
|
|||||||
await this.guards.dismissModals();
|
await this.guards.dismissModals();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleLogin(): Promise<AutomationResult> {
|
private async handleLogin(): Promise<AutomationResultDTO> {
|
||||||
return this.helpers.handleLogin();
|
return this.helpers.handleLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +145,14 @@ export class WizardStepOrchestrator {
|
|||||||
await this.navigator.waitForStep(stepNumber);
|
await this.navigator.waitForStep(stepNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clickAction(action: string): Promise<ClickResult> {
|
private async clickAction(action: string): Promise<ClickResultDTO> {
|
||||||
return this.interactor.clickAction(action);
|
return this.interactor.clickAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fillFieldWithFallback(
|
private async fillFieldWithFallback(
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
value: string,
|
value: string,
|
||||||
): Promise<FormFillResult> {
|
): Promise<FormFillResultDTO> {
|
||||||
return this.interactor.fillFieldWithFallback(fieldName, value);
|
return this.interactor.fillFieldWithFallback(fieldName, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +198,7 @@ export class WizardStepOrchestrator {
|
|||||||
private async fillField(
|
private async fillField(
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
value: string,
|
value: string,
|
||||||
): Promise<FormFillResult> {
|
): Promise<FormFillResultDTO> {
|
||||||
return this.interactor.fillField(fieldName, value);
|
return this.interactor.fillField(fieldName, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +264,7 @@ export class WizardStepOrchestrator {
|
|||||||
async executeStep(
|
async executeStep(
|
||||||
stepId: StepId,
|
stepId: StepId,
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
): Promise<AutomationResult> {
|
): Promise<AutomationResultDTO> {
|
||||||
if (!this.page) {
|
if (!this.page) {
|
||||||
return { success: false, error: 'Browser not connected' };
|
return { success: false, error: 'Browser not connected' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
import type {
|
import type { FormFillResultDTO } from '../../../../application/dto/FormFillResultDTO';
|
||||||
FormFillResult,
|
import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO';
|
||||||
ClickResult,
|
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
|
||||||
ModalResult,
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
} from '../../../../application/ports/AutomationResults';
|
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
|
||||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||||
import { IRACING_SELECTORS, IRACING_TIMEOUTS } from './IRacingSelectors';
|
import { IRACING_SELECTORS, IRACING_TIMEOUTS } from './IRacingSelectors';
|
||||||
@@ -17,7 +15,7 @@ export class IRacingDomInteractor {
|
|||||||
private readonly config: Required<PlaywrightConfig>,
|
private readonly config: Required<PlaywrightConfig>,
|
||||||
private readonly browserSession: PlaywrightBrowserSession,
|
private readonly browserSession: PlaywrightBrowserSession,
|
||||||
private readonly safeClickService: SafeClickService,
|
private readonly safeClickService: SafeClickService,
|
||||||
private readonly logger?: ILogger,
|
private readonly logger?: LoggerPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||||
@@ -42,7 +40,7 @@ export class IRacingDomInteractor {
|
|||||||
|
|
||||||
// ===== Public port-facing operations =====
|
// ===== Public port-facing operations =====
|
||||||
|
|
||||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
||||||
@@ -104,7 +102,7 @@ export class IRacingDomInteractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickElement(target: string): Promise<ClickResult> {
|
async clickElement(target: string): Promise<ClickResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, target, error: 'Browser not connected' };
|
return { success: false, target, error: 'Browser not connected' };
|
||||||
@@ -124,7 +122,7 @@ export class IRacingDomInteractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
async handleModal(stepId: StepId, action: string): Promise<ModalResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, stepId: stepId.value, action, error: 'Browser not connected' };
|
return { success: false, stepId: stepId.value, action, error: 'Browser not connected' };
|
||||||
@@ -156,7 +154,7 @@ export class IRacingDomInteractor {
|
|||||||
|
|
||||||
// ===== Public interaction helpers used by adapter steps =====
|
// ===== Public interaction helpers used by adapter steps =====
|
||||||
|
|
||||||
async fillField(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
||||||
@@ -208,7 +206,7 @@ export class IRacingDomInteractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fillFieldWithFallback(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillFieldWithFallback(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
|
||||||
@@ -249,7 +247,7 @@ export class IRacingDomInteractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickAction(action: string): Promise<ClickResult> {
|
async clickAction(action: string): Promise<ClickResultDTO> {
|
||||||
const page = this.browserSession.getPage();
|
const page = this.browserSession.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, target: action, error: 'Browser not connected' };
|
return { success: false, target: action, error: 'Browser not connected' };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||||
import type { NavigationResult, WaitResult } from '../../../../application/ports/AutomationResults';
|
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||||
|
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||||
import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors';
|
import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors';
|
||||||
@@ -23,7 +24,7 @@ export class IRacingDomNavigator {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: Required<PlaywrightConfig>,
|
private readonly config: Required<PlaywrightConfig>,
|
||||||
private readonly browserSession: PlaywrightBrowserSession,
|
private readonly browserSession: PlaywrightBrowserSession,
|
||||||
private readonly logger?: ILogger,
|
private readonly logger?: LoggerPort,
|
||||||
private readonly onWizardDismissed?: () => Promise<void>,
|
private readonly onWizardDismissed?: () => Promise<void>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export class IRacingDomNavigator {
|
|||||||
return this.browserSession.getPage();
|
return this.browserSession.getPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateToPage(url: string): Promise<NavigationResult> {
|
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||||
const page = this.getPage();
|
const page = this.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, url, loadTime: 0, error: 'Browser not connected' };
|
return { success: false, url, loadTime: 0, error: 'Browser not connected' };
|
||||||
@@ -78,7 +79,7 @@ export class IRacingDomNavigator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
|
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO> {
|
||||||
const page = this.getPage();
|
const page = this.getPage();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return { success: false, target, waitedMs: 0, found: false, error: 'Browser not connected' };
|
return { success: false, target, waitedMs: 0, found: false, error: 'Browser not connected' };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
|
import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
|
||||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||||
@@ -8,7 +8,7 @@ export class SafeClickService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: Required<PlaywrightConfig>,
|
private readonly config: Required<PlaywrightConfig>,
|
||||||
private readonly browserSession: PlaywrightBrowserSession,
|
private readonly browserSession: PlaywrightBrowserSession,
|
||||||
private readonly logger?: ILogger,
|
private readonly logger?: LoggerPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
|
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||||
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
|
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||||
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
|
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||||
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
|
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||||
|
|
||||||
|
type ValidationResult = {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real Automation Engine Adapter.
|
* Real Automation Engine Adapter.
|
||||||
@@ -22,13 +27,13 @@ import { StepTransitionValidator } from '@gridpilot/automation/domain/services/S
|
|||||||
* browser automation when available. See docs/ARCHITECTURE.md
|
* browser automation when available. See docs/ARCHITECTURE.md
|
||||||
* for the updated automation strategy.
|
* for the updated automation strategy.
|
||||||
*/
|
*/
|
||||||
export class AutomationEngineAdapter implements IAutomationEngine {
|
export class AutomationEngineAdapter implements AutomationEnginePort {
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
private automationPromise: Promise<void> | null = null;
|
private automationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly browserAutomation: IBrowserAutomation,
|
private readonly browserAutomation: IBrowserAutomation,
|
||||||
private readonly sessionRepository: ISessionRepository
|
private readonly sessionRepository: SessionRepositoryPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
|
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||||
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
|
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||||
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
|
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||||
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
|
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||||
|
|
||||||
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
type ValidationResult = {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MockAutomationEngineAdapter implements AutomationEnginePort {
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
private automationPromise: Promise<void> | null = null;
|
private automationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly browserAutomation: IBrowserAutomation,
|
private readonly browserAutomation: IBrowserAutomation,
|
||||||
private readonly sessionRepository: ISessionRepository
|
private readonly sessionRepository: SessionRepositoryPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||||
import {
|
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||||
NavigationResult,
|
import type { FormFillResultDTO } from '../../../../application/dto/FormFillResultDTO';
|
||||||
FormFillResult,
|
import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO';
|
||||||
ClickResult,
|
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||||
WaitResult,
|
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
|
||||||
ModalResult,
|
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
|
||||||
AutomationResult,
|
|
||||||
} from '../../../../application/ports/AutomationResults';
|
|
||||||
|
|
||||||
interface MockConfig {
|
interface MockConfig {
|
||||||
simulateFailures?: boolean;
|
simulateFailures?: boolean;
|
||||||
@@ -37,7 +35,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<AutomationResult> {
|
async connect(): Promise<AutomationResultDTO> {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -50,7 +48,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateToPage(url: string): Promise<NavigationResult> {
|
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||||
const delay = this.randomDelay(200, 800);
|
const delay = this.randomDelay(200, 800);
|
||||||
await this.sleep(delay);
|
await this.sleep(delay);
|
||||||
return {
|
return {
|
||||||
@@ -60,7 +58,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
async fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||||
const delay = this.randomDelay(100, 500);
|
const delay = this.randomDelay(100, 500);
|
||||||
await this.sleep(delay);
|
await this.sleep(delay);
|
||||||
return {
|
return {
|
||||||
@@ -70,7 +68,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickElement(selector: string): Promise<ClickResult> {
|
async clickElement(selector: string): Promise<ClickResultDTO> {
|
||||||
const delay = this.randomDelay(50, 300);
|
const delay = this.randomDelay(50, 300);
|
||||||
await this.sleep(delay);
|
await this.sleep(delay);
|
||||||
return {
|
return {
|
||||||
@@ -79,7 +77,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
|
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResultDTO> {
|
||||||
const delay = this.randomDelay(100, 1000);
|
const delay = this.randomDelay(100, 1000);
|
||||||
|
|
||||||
await this.sleep(delay);
|
await this.sleep(delay);
|
||||||
@@ -92,7 +90,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
async handleModal(stepId: StepId, action: string): Promise<ModalResultDTO> {
|
||||||
if (!stepId.isModalStep()) {
|
if (!stepId.isModalStep()) {
|
||||||
throw new Error(`Step ${stepId.value} is not a modal step`);
|
throw new Error(`Step ${stepId.value} is not a modal step`);
|
||||||
}
|
}
|
||||||
@@ -106,7 +104,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO> {
|
||||||
if (this.shouldSimulateFailure()) {
|
if (this.shouldSimulateFailure()) {
|
||||||
throw new Error(`Simulated failure at step ${stepId.value}`);
|
throw new Error(`Simulated failure at step ${stepId.value}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import { Result } from '../../../shared/result/Result';
|
import { Result } from '../../../../shared/result/Result';
|
||||||
import type { ICheckoutConfirmationPort, CheckoutConfirmationRequest } from '../../../application/ports/ICheckoutConfirmationPort';
|
import type { CheckoutConfirmationPort } from '../../../application/ports/CheckoutConfirmationPort';
|
||||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
import type { CheckoutConfirmationRequestDTO } from '../../../application/dto/CheckoutConfirmationRequestDTO';
|
||||||
|
import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation';
|
||||||
|
|
||||||
export class ElectronCheckoutConfirmationAdapter implements ICheckoutConfirmationPort {
|
export class ElectronCheckoutConfirmationAdapter implements CheckoutConfirmationPort {
|
||||||
private mainWindow: BrowserWindow;
|
private mainWindow: BrowserWindow;
|
||||||
private pendingConfirmation: {
|
private pendingConfirmation: {
|
||||||
resolve: (confirmation: CheckoutConfirmation) => void;
|
resolve: (confirmation: CheckoutConfirmation) => void;
|
||||||
@@ -40,7 +41,7 @@ export class ElectronCheckoutConfirmationAdapter implements ICheckoutConfirmatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestCheckoutConfirmation(
|
async requestCheckoutConfirmation(
|
||||||
request: CheckoutConfirmationRequest
|
request: CheckoutConfirmationRequestDTO
|
||||||
): Promise<Result<CheckoutConfirmation>> {
|
): Promise<Result<CheckoutConfirmation>> {
|
||||||
try {
|
try {
|
||||||
// Only allow one pending confirmation at a time
|
// Only allow one pending confirmation at a time
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ILogger, LogContext } from '../../../application/ports/ILogger';
|
import type { LoggerPort } from '../../../application/ports/LoggerPort';
|
||||||
|
import type { LogContext } from '../../../application/ports/LoggerContext';
|
||||||
|
|
||||||
export class NoOpLogAdapter implements ILogger {
|
export class NoOpLogAdapter implements LoggerPort {
|
||||||
debug(_message: string, _context?: LogContext): void {}
|
debug(_message: string, _context?: LogContext): void {}
|
||||||
|
|
||||||
info(_message: string, _context?: LogContext): void {}
|
info(_message: string, _context?: LogContext): void {}
|
||||||
@@ -11,7 +12,7 @@ export class NoOpLogAdapter implements ILogger {
|
|||||||
|
|
||||||
fatal(_message: string, _error?: Error, _context?: LogContext): void {}
|
fatal(_message: string, _error?: Error, _context?: LogContext): void {}
|
||||||
|
|
||||||
child(_context: LogContext): ILogger {
|
child(_context: LogContext): LoggerPort {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ILogger, LogContext, LogLevel } from '../../../application/ports/ILogger';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
|
import type { LogContext } from '@gridpilot/automation/application/ports/LoggerContext';
|
||||||
|
import type { LogLevel } from '@gridpilot/automation/application/ports/LoggerLogLevel';
|
||||||
import { loadLoggingConfig, type LoggingEnvironmentConfig } from '../../config/LoggingConfig';
|
import { loadLoggingConfig, type LoggingEnvironmentConfig } from '../../config/LoggingConfig';
|
||||||
|
|
||||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||||
@@ -18,7 +20,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|||||||
*
|
*
|
||||||
* This provides structured JSON logging to stdout with the same interface.
|
* This provides structured JSON logging to stdout with the same interface.
|
||||||
*/
|
*/
|
||||||
export class PinoLogAdapter implements ILogger {
|
export class PinoLogAdapter implements LoggerPort {
|
||||||
private readonly config: LoggingEnvironmentConfig;
|
private readonly config: LoggingEnvironmentConfig;
|
||||||
private readonly baseContext: LogContext;
|
private readonly baseContext: LogContext;
|
||||||
private readonly levelPriority: number;
|
private readonly levelPriority: number;
|
||||||
@@ -106,7 +108,7 @@ export class PinoLogAdapter implements ILogger {
|
|||||||
this.log('fatal', message, context, error);
|
this.log('fatal', message, context, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
child(context: LogContext): ILogger {
|
child(context: LogContext): LoggerPort {
|
||||||
return new PinoLogAdapter(this.config, { ...this.baseContext, ...context });
|
return new PinoLogAdapter(this.config, { ...this.baseContext, ...context });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LogLevel } from '../../application/ports/ILogger';
|
import type { LogLevel } from '@gridpilot/automation/application/ports/LoggerLogLevel';
|
||||||
|
|
||||||
export type LogEnvironment = 'development' | 'production' | 'test';
|
export type LogEnvironment = 'development' | 'production' | 'test';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||||
import { SessionStateValue } from '@gridpilot/automation/domain/value-objects/SessionState';
|
import { SessionStateValue } from '../../domain/value-objects/SessionState';
|
||||||
import { ISessionRepository } from '../../application/ports/ISessionRepository';
|
import type { SessionRepositoryPort } from '../../application/ports/SessionRepositoryPort';
|
||||||
|
|
||||||
export class InMemorySessionRepository implements ISessionRepository {
|
export class InMemorySessionRepository implements SessionRepositoryPort {
|
||||||
private sessions: Map<string, AutomationSession> = new Map();
|
private sessions: Map<string, AutomationSession> = new Map();
|
||||||
|
|
||||||
async save(session: AutomationSession): Promise<void> {
|
async save(session: AutomationSession): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": "..",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": false
|
"declarationMap": false
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './src/faker';
|
|
||||||
export * from './src/images';
|
|
||||||
export * from './src/racing/StaticRacingSeed';
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export * from './memberships';
|
|
||||||
export * from './registrations';
|
|
||||||
// Re-export selected team helpers but avoid getCurrentDriverId to prevent conflicts.
|
|
||||||
export {
|
|
||||||
getAllTeams,
|
|
||||||
getTeam,
|
|
||||||
getTeamMembers,
|
|
||||||
getTeamMembership,
|
|
||||||
getTeamJoinRequests,
|
|
||||||
getDriverTeam,
|
|
||||||
isTeamOwnerOrManager,
|
|
||||||
removeTeamMember,
|
|
||||||
updateTeamMemberRole,
|
|
||||||
createTeam,
|
|
||||||
joinTeam,
|
|
||||||
requestToJoinTeam,
|
|
||||||
leaveTeam,
|
|
||||||
approveTeamJoinRequest,
|
|
||||||
rejectTeamJoinRequest,
|
|
||||||
updateTeam,
|
|
||||||
} from './teams';
|
|
||||||
|
|
||||||
// Re-export domain types for legacy callers (type-only)
|
|
||||||
export type {
|
|
||||||
LeagueMembership,
|
|
||||||
MembershipRole,
|
|
||||||
MembershipStatus,
|
|
||||||
JoinRequest,
|
|
||||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
|
||||||
|
|
||||||
export type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
Team,
|
|
||||||
TeamMembership,
|
|
||||||
TeamJoinRequest,
|
|
||||||
TeamRole,
|
|
||||||
TeamMembershipStatus,
|
|
||||||
} from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gridpilot/racing-application",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"main": "./index.ts",
|
|
||||||
"types": "./index.ts",
|
|
||||||
"dependencies": {
|
|
||||||
"@gridpilot/racing": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './src/StaticRacingSeed';
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gridpilot/racing-demo-infrastructure",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "./index.ts",
|
|
||||||
"types": "./index.ts",
|
|
||||||
"dependencies": {
|
|
||||||
"@gridpilot/racing": "0.1.0",
|
|
||||||
"@gridpilot/social": "0.1.0",
|
|
||||||
"@gridpilot/demo-support": "0.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
|
||||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
|
||||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
|
||||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
|
||||||
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
|
||||||
|
|
||||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
|
||||||
import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO';
|
|
||||||
import { faker } from '@gridpilot/demo-support';
|
|
||||||
import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '@gridpilot/demo-support';
|
|
||||||
|
|
||||||
export type RacingMembership = {
|
|
||||||
driverId: string;
|
|
||||||
leagueId: string;
|
|
||||||
teamId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Friendship = {
|
|
||||||
driverId: string;
|
|
||||||
friendId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DemoTeamDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
tag: string;
|
|
||||||
description: string;
|
|
||||||
logoUrl: string;
|
|
||||||
primaryLeagueId: string;
|
|
||||||
memberCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RacingSeedData = {
|
|
||||||
drivers: Driver[];
|
|
||||||
leagues: League[];
|
|
||||||
races: Race[];
|
|
||||||
results: Result[];
|
|
||||||
standings: Standing[];
|
|
||||||
memberships: RacingMembership[];
|
|
||||||
friendships: Friendship[];
|
|
||||||
feedEvents: FeedItem[];
|
|
||||||
teams: DemoTeamDTO[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const POINTS_TABLE: Record<number, number> = {
|
|
||||||
1: 25,
|
|
||||||
2: 18,
|
|
||||||
3: 15,
|
|
||||||
4: 12,
|
|
||||||
5: 10,
|
|
||||||
6: 8,
|
|
||||||
7: 6,
|
|
||||||
8: 4,
|
|
||||||
9: 2,
|
|
||||||
10: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
function pickOne<T>(items: readonly T[]): T {
|
|
||||||
return items[Math.floor(faker.number.int({ min: 0, max: items.length - 1 }))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDrivers(count: number): Driver[] {
|
|
||||||
const drivers: Driver[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const id = `driver-${i + 1}`;
|
|
||||||
const name = faker.person.fullName();
|
|
||||||
const country = faker.location.countryCode('alpha-2');
|
|
||||||
const iracingId = faker.string.numeric(6);
|
|
||||||
|
|
||||||
drivers.push(
|
|
||||||
Driver.create({
|
|
||||||
id,
|
|
||||||
iracingId,
|
|
||||||
name,
|
|
||||||
country,
|
|
||||||
bio: faker.lorem.sentence(),
|
|
||||||
joinedAt: faker.date.past(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return drivers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLeagues(ownerIds: string[]): League[] {
|
|
||||||
const leagueNames = [
|
|
||||||
'Global GT Masters',
|
|
||||||
'Midnight Endurance Series',
|
|
||||||
'Virtual Touring Cup',
|
|
||||||
'Sprint Challenge League',
|
|
||||||
'Club Racers Collective',
|
|
||||||
'Sim Racing Alliance',
|
|
||||||
'Pacific Time Attack',
|
|
||||||
'Nordic Night Series',
|
|
||||||
];
|
|
||||||
|
|
||||||
const leagues: League[] = [];
|
|
||||||
const leagueCount = 6 + faker.number.int({ min: 0, max: 2 });
|
|
||||||
|
|
||||||
for (let i = 0; i < leagueCount; i++) {
|
|
||||||
const id = `league-${i + 1}`;
|
|
||||||
const name = leagueNames[i] ?? faker.company.name();
|
|
||||||
const ownerId = pickOne(ownerIds);
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
|
|
||||||
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
|
|
||||||
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
|
|
||||||
};
|
|
||||||
|
|
||||||
leagues.push(
|
|
||||||
League.create({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description: faker.lorem.sentence(),
|
|
||||||
ownerId,
|
|
||||||
settings,
|
|
||||||
createdAt: faker.date.past(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return leagues;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTeams(leagues: League[]): DemoTeamDTO[] {
|
|
||||||
const teams: DemoTeamDTO[] = [];
|
|
||||||
const teamCount = 24 + faker.number.int({ min: 0, max: 12 });
|
|
||||||
|
|
||||||
for (let i = 0; i < teamCount; i++) {
|
|
||||||
const id = `team-${i + 1}`;
|
|
||||||
const primaryLeague = pickOne(leagues);
|
|
||||||
const name = faker.company.name();
|
|
||||||
const tag = faker.string.alpha({ length: 4 }).toUpperCase();
|
|
||||||
const memberCount = faker.number.int({ min: 2, max: 8 });
|
|
||||||
|
|
||||||
teams.push({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
tag,
|
|
||||||
description: faker.lorem.sentence(),
|
|
||||||
logoUrl: getTeamLogo(id),
|
|
||||||
primaryLeagueId: primaryLeague.id,
|
|
||||||
memberCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMemberships(
|
|
||||||
drivers: Driver[],
|
|
||||||
leagues: League[],
|
|
||||||
teams: DemoTeamDTO[],
|
|
||||||
): RacingMembership[] {
|
|
||||||
const memberships: RacingMembership[] = [];
|
|
||||||
|
|
||||||
const teamsByLeague = new Map<string, DemoTeamDTO[]>();
|
|
||||||
teams.forEach((team) => {
|
|
||||||
const list = teamsByLeague.get(team.primaryLeagueId) ?? [];
|
|
||||||
list.push(team);
|
|
||||||
teamsByLeague.set(team.primaryLeagueId, list);
|
|
||||||
});
|
|
||||||
|
|
||||||
drivers.forEach((driver) => {
|
|
||||||
// Each driver participates in 1–3 leagues
|
|
||||||
const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) });
|
|
||||||
const shuffledLeagues = faker.helpers.shuffle(leagues).slice(0, leagueSampleSize);
|
|
||||||
|
|
||||||
shuffledLeagues.forEach((league) => {
|
|
||||||
const leagueTeams = teamsByLeague.get(league.id) ?? [];
|
|
||||||
const team =
|
|
||||||
leagueTeams.length > 0 && faker.datatype.boolean()
|
|
||||||
? pickOne(leagueTeams)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
memberships.push({
|
|
||||||
driverId: driver.id,
|
|
||||||
leagueId: league.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return memberships;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRaces(leagues: League[]): Race[] {
|
|
||||||
const races: Race[] = [];
|
|
||||||
const raceCount = 60 + faker.number.int({ min: 0, max: 20 });
|
|
||||||
|
|
||||||
const tracks = [
|
|
||||||
'Monza GP',
|
|
||||||
'Spa-Francorchamps',
|
|
||||||
'Suzuka',
|
|
||||||
'Mount Panorama',
|
|
||||||
'Silverstone GP',
|
|
||||||
'Interlagos',
|
|
||||||
'Imola',
|
|
||||||
'Laguna Seca',
|
|
||||||
];
|
|
||||||
|
|
||||||
const cars = [
|
|
||||||
'GT3 – Porsche 911',
|
|
||||||
'GT3 – BMW M4',
|
|
||||||
'LMP3 Prototype',
|
|
||||||
'GT4 – Alpine',
|
|
||||||
'Touring – Civic',
|
|
||||||
];
|
|
||||||
|
|
||||||
const baseDate = new Date();
|
|
||||||
|
|
||||||
for (let i = 0; i < raceCount; i++) {
|
|
||||||
const id = `race-${i + 1}`;
|
|
||||||
const league = pickOne(leagues);
|
|
||||||
const offsetDays = faker.number.int({ min: -30, max: 45 });
|
|
||||||
const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000);
|
|
||||||
const status = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
|
|
||||||
|
|
||||||
races.push(
|
|
||||||
Race.create({
|
|
||||||
id,
|
|
||||||
leagueId: league.id,
|
|
||||||
scheduledAt,
|
|
||||||
track: faker.helpers.arrayElement(tracks),
|
|
||||||
car: faker.helpers.arrayElement(cars),
|
|
||||||
sessionType: 'race',
|
|
||||||
status,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return races;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createResults(drivers: Driver[], races: Race[]): Result[] {
|
|
||||||
const results: Result[] = [];
|
|
||||||
|
|
||||||
const completedRaces = races.filter((race) => race.status === 'completed');
|
|
||||||
|
|
||||||
completedRaces.forEach((race) => {
|
|
||||||
const participantCount = faker.number.int({ min: 20, max: 32 });
|
|
||||||
const shuffledDrivers = faker.helpers.shuffle(drivers).slice(0, participantCount);
|
|
||||||
|
|
||||||
shuffledDrivers.forEach((driver, index) => {
|
|
||||||
const position = index + 1;
|
|
||||||
const startPosition = faker.number.int({ min: 1, max: participantCount });
|
|
||||||
const fastestLap = 90_000 + index * 250 + faker.number.int({ min: 0, max: 2_000 });
|
|
||||||
const incidents = faker.number.int({ min: 0, max: 6 });
|
|
||||||
|
|
||||||
results.push(
|
|
||||||
Result.create({
|
|
||||||
id: `${race.id}-${driver.id}`,
|
|
||||||
raceId: race.id,
|
|
||||||
driverId: driver.id,
|
|
||||||
position,
|
|
||||||
startPosition,
|
|
||||||
fastestLap,
|
|
||||||
incidents,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStandings(leagues: League[], results: Result[]): Standing[] {
|
|
||||||
const standingsByLeague = new Map<string, Standing[]>();
|
|
||||||
|
|
||||||
leagues.forEach((league) => {
|
|
||||||
const leagueRaceIds = new Set(
|
|
||||||
results
|
|
||||||
.filter((result) => {
|
|
||||||
return result.raceId.startsWith('race-');
|
|
||||||
})
|
|
||||||
.map((result) => result.raceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
const leagueResults = results.filter((result) => leagueRaceIds.has(result.raceId));
|
|
||||||
|
|
||||||
const standingsMap = new Map<string, Standing>();
|
|
||||||
|
|
||||||
leagueResults.forEach((result) => {
|
|
||||||
const key = result.driverId;
|
|
||||||
let standing = standingsMap.get(key);
|
|
||||||
|
|
||||||
if (!standing) {
|
|
||||||
standing = Standing.create({
|
|
||||||
leagueId: league.id,
|
|
||||||
driverId: result.driverId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
standing = standing.addRaceResult(result.position, POINTS_TABLE);
|
|
||||||
standingsMap.set(key, standing);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedStandings = Array.from(standingsMap.values()).sort((a, b) => {
|
|
||||||
if (b.points !== a.points) {
|
|
||||||
return b.points - a.points;
|
|
||||||
}
|
|
||||||
if (b.wins !== a.wins) {
|
|
||||||
return b.wins - a.wins;
|
|
||||||
}
|
|
||||||
return b.racesCompleted - a.racesCompleted;
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalizedStandings = sortedStandings.map((standing, index) =>
|
|
||||||
standing.updatePosition(index + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
standingsByLeague.set(league.id, finalizedStandings);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(standingsByLeague.values()).flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFriendships(drivers: Driver[]): Friendship[] {
|
|
||||||
const friendships: Friendship[] = [];
|
|
||||||
|
|
||||||
drivers.forEach((driver, index) => {
|
|
||||||
const friendCount = faker.number.int({ min: 3, max: 8 });
|
|
||||||
for (let offset = 1; offset <= friendCount; offset++) {
|
|
||||||
const friendIndex = (index + offset) % drivers.length;
|
|
||||||
const friend = drivers[friendIndex];
|
|
||||||
if (friend.id === driver.id) continue;
|
|
||||||
|
|
||||||
friendships.push({
|
|
||||||
driverId: driver.id,
|
|
||||||
friendId: friend.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return friendships;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedEvents(
|
|
||||||
drivers: Driver[],
|
|
||||||
leagues: League[],
|
|
||||||
races: Race[],
|
|
||||||
friendships: Friendship[],
|
|
||||||
): FeedItem[] {
|
|
||||||
const events: FeedItem[] = [];
|
|
||||||
const now = new Date();
|
|
||||||
const completedRaces = races.filter((race) => race.status === 'completed');
|
|
||||||
const globalDrivers = faker.helpers.shuffle(drivers).slice(0, 10);
|
|
||||||
|
|
||||||
globalDrivers.forEach((driver, index) => {
|
|
||||||
const league = pickOne(leagues);
|
|
||||||
const race = completedRaces[index % Math.max(1, completedRaces.length)];
|
|
||||||
const minutesAgo = 15 + index * 10;
|
|
||||||
|
|
||||||
const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `friend-joined-league:${driver.id}:${minutesAgo}`,
|
|
||||||
type: 'friend-joined-league',
|
|
||||||
timestamp: baseTimestamp,
|
|
||||||
actorDriverId: driver.id,
|
|
||||||
leagueId: league.id,
|
|
||||||
headline: `${driver.name} joined ${league.name}`,
|
|
||||||
body: 'They are now registered for the full season.',
|
|
||||||
ctaLabel: 'View league',
|
|
||||||
ctaHref: `/leagues/${league.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `friend-finished-race:${driver.id}:${minutesAgo}`,
|
|
||||||
type: 'friend-finished-race',
|
|
||||||
timestamp: new Date(baseTimestamp.getTime() - 10 * 60 * 1000),
|
|
||||||
actorDriverId: driver.id,
|
|
||||||
leagueId: race.leagueId,
|
|
||||||
raceId: race.id,
|
|
||||||
position: (index % 5) + 1,
|
|
||||||
headline: `${driver.name} finished P${(index % 5) + 1} at ${race.track}`,
|
|
||||||
body: `${driver.name} secured a strong result in ${race.car}.`,
|
|
||||||
ctaLabel: 'View results',
|
|
||||||
ctaHref: `/races/${race.id}/results`,
|
|
||||||
});
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `league-highlight:${league.id}:${minutesAgo}`,
|
|
||||||
type: 'league-highlight',
|
|
||||||
timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000),
|
|
||||||
leagueId: league.id,
|
|
||||||
headline: `${league.name} active with ${drivers.length}+ drivers`,
|
|
||||||
body: 'Participation is growing. Perfect time to join the grid.',
|
|
||||||
ctaLabel: 'Explore league',
|
|
||||||
ctaHref: `/leagues/${league.id}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorted = events
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStaticRacingSeed(seed: number): RacingSeedData {
|
|
||||||
faker.seed(seed);
|
|
||||||
|
|
||||||
const drivers = createDrivers(96);
|
|
||||||
const leagues = createLeagues(drivers.slice(0, 12).map((d) => d.id));
|
|
||||||
const teams = createTeams(leagues);
|
|
||||||
const memberships = createMemberships(drivers, leagues, teams);
|
|
||||||
const races = createRaces(leagues);
|
|
||||||
const results = createResults(drivers, races);
|
|
||||||
const friendships = createFriendships(drivers);
|
|
||||||
const feedEvents = createFeedEvents(drivers, leagues, races, friendships);
|
|
||||||
const standings = createStandings(leagues, results);
|
|
||||||
|
|
||||||
return {
|
|
||||||
drivers,
|
|
||||||
leagues,
|
|
||||||
races,
|
|
||||||
results,
|
|
||||||
standings,
|
|
||||||
memberships,
|
|
||||||
friendships,
|
|
||||||
feedEvents,
|
|
||||||
teams,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton seed used by website demo helpers.
|
|
||||||
* This mirrors the previous apps/website/lib/demo-data/index.ts behavior.
|
|
||||||
*/
|
|
||||||
const staticSeed = createStaticRacingSeed(42);
|
|
||||||
|
|
||||||
export const drivers = staticSeed.drivers;
|
|
||||||
export const leagues = staticSeed.leagues;
|
|
||||||
export const races = staticSeed.races;
|
|
||||||
export const results = staticSeed.results;
|
|
||||||
export const standings = staticSeed.standings;
|
|
||||||
export const teams = staticSeed.teams;
|
|
||||||
export const memberships = staticSeed.memberships;
|
|
||||||
export const friendships = staticSeed.friendships;
|
|
||||||
export const feedEvents = staticSeed.feedEvents;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derived friend DTOs for UI consumption.
|
|
||||||
* This preserves the previous demo-data `friends` shape.
|
|
||||||
*/
|
|
||||||
export const friends: FriendDTO[] = staticSeed.drivers.map((driver) => ({
|
|
||||||
driverId: driver.id,
|
|
||||||
displayName: driver.name,
|
|
||||||
avatarUrl: getDriverAvatar(driver.id),
|
|
||||||
isOnline: true,
|
|
||||||
lastSeen: new Date(),
|
|
||||||
primaryLeagueId: staticSeed.memberships.find((m) => m.driverId === driver.id)?.leagueId,
|
|
||||||
primaryTeamId: staticSeed.memberships.find((m) => m.driverId === driver.id)?.teamId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const topLeagues = leagues.map((league) => ({
|
|
||||||
...league,
|
|
||||||
bannerUrl: getLeagueBanner(league.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type RaceWithResultsDTO = {
|
|
||||||
raceId: string;
|
|
||||||
track: string;
|
|
||||||
car: string;
|
|
||||||
scheduledAt: Date;
|
|
||||||
winnerDriverId: string;
|
|
||||||
winnerName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getUpcomingRaces(limit?: number): readonly Race[] {
|
|
||||||
const upcoming = races.filter((race) => race.status === 'scheduled');
|
|
||||||
const sorted = upcoming
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
|
||||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[] {
|
|
||||||
const completedRaces = races.filter((race) => race.status === 'completed');
|
|
||||||
|
|
||||||
const joined = completedRaces.map((race) => {
|
|
||||||
const raceResults = results
|
|
||||||
.filter((result) => result.raceId === race.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.position - b.position);
|
|
||||||
const winner = raceResults[0];
|
|
||||||
const winnerDriver =
|
|
||||||
winner && drivers.find((driver) => driver.id === winner.driverId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
raceId: race.id,
|
|
||||||
track: race.track,
|
|
||||||
car: race.car,
|
|
||||||
scheduledAt: race.scheduledAt,
|
|
||||||
winnerDriverId: winner?.driverId ?? '',
|
|
||||||
winnerName: winnerDriver?.name ?? 'Winner',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorted = joined
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
|
||||||
|
|
||||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": false,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": false
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gridpilot/racing-infrastructure",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"main": "./index.ts",
|
|
||||||
"types": "./index.ts",
|
|
||||||
"dependencies": {
|
|
||||||
"@gridpilot/racing": "*",
|
|
||||||
"uuid": "^9.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
packages/racing/application/dto/CreateTeamCommandDTO.ts
Normal file
13
packages/racing/application/dto/CreateTeamCommandDTO.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Team } from '../../domain/entities/Team';
|
||||||
|
|
||||||
|
export interface CreateTeamCommandDTO {
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: string;
|
||||||
|
leagues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTeamResultDTO {
|
||||||
|
team: Team;
|
||||||
|
}
|
||||||
8
packages/racing/application/dto/DriverDTO.ts
Normal file
8
packages/racing/application/dto/DriverDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type DriverDTO = {
|
||||||
|
id: string;
|
||||||
|
iracingId: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
bio?: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
4
packages/racing/application/dto/JoinLeagueCommandDTO.ts
Normal file
4
packages/racing/application/dto/JoinLeagueCommandDTO.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface JoinLeagueCommandDTO {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
13
packages/racing/application/dto/LeagueDTO.ts
Normal file
13
packages/racing/application/dto/LeagueDTO.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type LeagueDTO = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: string;
|
||||||
|
settings: {
|
||||||
|
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||||
|
sessionDuration?: number;
|
||||||
|
qualifyingFormat?: 'single-lap' | 'open';
|
||||||
|
customPoints?: Record<number, number>;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
9
packages/racing/application/dto/RaceDTO.ts
Normal file
9
packages/racing/application/dto/RaceDTO.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type RaceDTO = {
|
||||||
|
id: string;
|
||||||
|
leagueId: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
sessionType: 'practice' | 'qualifying' | 'race';
|
||||||
|
status: 'scheduled' | 'completed' | 'cancelled';
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface IsDriverRegisteredForRaceQueryParamsDTO {
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRaceRegistrationsQueryParamsDTO {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RegisterForRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
9
packages/racing/application/dto/ResultDTO.ts
Normal file
9
packages/racing/application/dto/ResultDTO.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type ResultDTO = {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
fastestLap: number;
|
||||||
|
incidents: number;
|
||||||
|
startPosition: number;
|
||||||
|
};
|
||||||
8
packages/racing/application/dto/StandingDTO.ts
Normal file
8
packages/racing/application/dto/StandingDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type StandingDTO = {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
points: number;
|
||||||
|
wins: number;
|
||||||
|
position: number;
|
||||||
|
racesCompleted: number;
|
||||||
|
};
|
||||||
54
packages/racing/application/dto/TeamCommandAndQueryDTO.ts
Normal file
54
packages/racing/application/dto/TeamCommandAndQueryDTO.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Team, TeamJoinRequest, TeamMembership } from '../../domain/entities/Team';
|
||||||
|
|
||||||
|
export interface JoinTeamCommandDTO {
|
||||||
|
teamId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveTeamCommandDTO {
|
||||||
|
teamId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApproveTeamJoinRequestCommandDTO {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectTeamJoinRequestCommandDTO {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTeamCommandDTO {
|
||||||
|
teamId: string;
|
||||||
|
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetAllTeamsQueryResultDTO = Team[];
|
||||||
|
|
||||||
|
export interface GetTeamDetailsQueryParamsDTO {
|
||||||
|
teamId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTeamDetailsQueryResultDTO {
|
||||||
|
team: Team;
|
||||||
|
membership: TeamMembership | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTeamMembersQueryParamsDTO {
|
||||||
|
teamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTeamJoinRequestsQueryParamsDTO {
|
||||||
|
teamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDriverTeamQueryParamsDTO {
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDriverTeamQueryResultDTO {
|
||||||
|
team: Team;
|
||||||
|
membership: TeamMembership;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface WithdrawFromRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
@@ -1,25 +1,19 @@
|
|||||||
export * from './services/memberships';
|
export * from './use-cases/JoinLeagueUseCase';
|
||||||
export * from './services/registrations';
|
export * from './use-cases/RegisterForRaceUseCase';
|
||||||
|
export * from './use-cases/WithdrawFromRaceUseCase';
|
||||||
// Re-export selected team helpers but avoid getCurrentDriverId to prevent conflicts.
|
export * from './use-cases/IsDriverRegisteredForRaceQuery';
|
||||||
export {
|
export * from './use-cases/GetRaceRegistrationsQuery';
|
||||||
getAllTeams,
|
export * from './use-cases/CreateTeamUseCase';
|
||||||
getTeam,
|
export * from './use-cases/JoinTeamUseCase';
|
||||||
getTeamMembers,
|
export * from './use-cases/LeaveTeamUseCase';
|
||||||
getTeamMembership,
|
export * from './use-cases/ApproveTeamJoinRequestUseCase';
|
||||||
getTeamJoinRequests,
|
export * from './use-cases/RejectTeamJoinRequestUseCase';
|
||||||
getDriverTeam,
|
export * from './use-cases/UpdateTeamUseCase';
|
||||||
isTeamOwnerOrManager,
|
export * from './use-cases/GetAllTeamsQuery';
|
||||||
removeTeamMember,
|
export * from './use-cases/GetTeamDetailsQuery';
|
||||||
updateTeamMemberRole,
|
export * from './use-cases/GetTeamMembersQuery';
|
||||||
createTeam,
|
export * from './use-cases/GetTeamJoinRequestsQuery';
|
||||||
joinTeam,
|
export * from './use-cases/GetDriverTeamQuery';
|
||||||
requestToJoinTeam,
|
|
||||||
leaveTeam,
|
|
||||||
approveTeamJoinRequest,
|
|
||||||
rejectTeamJoinRequest,
|
|
||||||
updateTeam,
|
|
||||||
} from './services/teams';
|
|
||||||
|
|
||||||
// Re-export domain types for legacy callers (type-only)
|
// Re-export domain types for legacy callers (type-only)
|
||||||
export type {
|
export type {
|
||||||
@@ -27,9 +21,9 @@ export type {
|
|||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
JoinRequest,
|
JoinRequest,
|
||||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
} from '../domain/entities/LeagueMembership';
|
||||||
|
|
||||||
export type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Team,
|
Team,
|
||||||
@@ -37,12 +31,10 @@ export type {
|
|||||||
TeamJoinRequest,
|
TeamJoinRequest,
|
||||||
TeamRole,
|
TeamRole,
|
||||||
TeamMembershipStatus,
|
TeamMembershipStatus,
|
||||||
} from '@gridpilot/racing/domain/entities/Team';
|
} from '../domain/entities/Team';
|
||||||
|
|
||||||
export type {
|
export type { DriverDTO } from './dto/DriverDTO';
|
||||||
DriverDTO,
|
export type { LeagueDTO } from './dto/LeagueDTO';
|
||||||
LeagueDTO,
|
export type { RaceDTO } from './dto/RaceDTO';
|
||||||
RaceDTO,
|
export type { ResultDTO } from './dto/ResultDTO';
|
||||||
ResultDTO,
|
export type { StandingDTO } from './dto/StandingDTO';
|
||||||
StandingDTO,
|
|
||||||
} from './mappers/EntityMappers';
|
|
||||||
@@ -5,63 +5,16 @@
|
|||||||
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
|
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
import { Driver } from '../../domain/entities/Driver';
|
||||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
import { League } from '../../domain/entities/League';
|
||||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
import { Race } from '../../domain/entities/Race';
|
||||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
import { Result } from '../../domain/entities/Result';
|
||||||
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
import { Standing } from '../../domain/entities/Standing';
|
||||||
|
import type { DriverDTO } from '../dto/DriverDTO';
|
||||||
export type DriverDTO = {
|
import type { LeagueDTO } from '../dto/LeagueDTO';
|
||||||
id: string;
|
import type { RaceDTO } from '../dto/RaceDTO';
|
||||||
iracingId: string;
|
import type { ResultDTO } from '../dto/ResultDTO';
|
||||||
name: string;
|
import type { StandingDTO } from '../dto/StandingDTO';
|
||||||
country: string;
|
|
||||||
bio?: string;
|
|
||||||
joinedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LeagueDTO = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
ownerId: string;
|
|
||||||
settings: {
|
|
||||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
|
||||||
sessionDuration?: number;
|
|
||||||
qualifyingFormat?: 'single-lap' | 'open';
|
|
||||||
customPoints?: Record<number, number>;
|
|
||||||
};
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RaceDTO = {
|
|
||||||
id: string;
|
|
||||||
leagueId: string;
|
|
||||||
scheduledAt: string;
|
|
||||||
track: string;
|
|
||||||
car: string;
|
|
||||||
sessionType: 'practice' | 'qualifying' | 'race';
|
|
||||||
status: 'scheduled' | 'completed' | 'cancelled';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResultDTO = {
|
|
||||||
id: string;
|
|
||||||
raceId: string;
|
|
||||||
driverId: string;
|
|
||||||
position: number;
|
|
||||||
fastestLap: number;
|
|
||||||
incidents: number;
|
|
||||||
startPosition: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StandingDTO = {
|
|
||||||
leagueId: string;
|
|
||||||
driverId: string;
|
|
||||||
points: number;
|
|
||||||
wins: number;
|
|
||||||
position: number;
|
|
||||||
racesCompleted: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class EntityMappers {
|
export class EntityMappers {
|
||||||
static toDriverDTO(driver: Driver | null): DriverDTO | null {
|
static toDriverDTO(driver: Driver | null): DriverDTO | null {
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory league membership data for alpha prototype
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
MembershipRole,
|
|
||||||
MembershipStatus,
|
|
||||||
LeagueMembership,
|
|
||||||
JoinRequest,
|
|
||||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
|
||||||
|
|
||||||
// In-memory storage
|
|
||||||
let memberships: LeagueMembership[] = [];
|
|
||||||
let joinRequests: JoinRequest[] = [];
|
|
||||||
|
|
||||||
// Current driver ID (matches the one in di-container)
|
|
||||||
const CURRENT_DRIVER_ID = 'driver-1';
|
|
||||||
|
|
||||||
// Initialize with seed data
|
|
||||||
export function initializeMembershipData() {
|
|
||||||
memberships = [
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
driverId: CURRENT_DRIVER_ID,
|
|
||||||
role: 'owner',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-01-15'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
driverId: 'driver-2',
|
|
||||||
role: 'member',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-02-01'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
leagueId: 'league-1',
|
|
||||||
driverId: 'driver-3',
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-02-15'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
joinRequests = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get membership for a driver in a league
|
|
||||||
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
|
|
||||||
return memberships.find(m => m.leagueId === leagueId && m.driverId === driverId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all members for a league
|
|
||||||
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
|
|
||||||
return memberships.filter(m => m.leagueId === leagueId && m.status === 'active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get pending join requests for a league
|
|
||||||
export function getJoinRequests(leagueId: string): JoinRequest[] {
|
|
||||||
return joinRequests.filter(r => r.leagueId === leagueId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join a league
|
|
||||||
export function joinLeague(leagueId: string, driverId: string): void {
|
|
||||||
const existing = getMembership(leagueId, driverId);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
|
||||||
}
|
|
||||||
|
|
||||||
memberships.push({
|
|
||||||
leagueId,
|
|
||||||
driverId,
|
|
||||||
role: 'member',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request to join a league (for invite-only leagues)
|
|
||||||
export function requestToJoin(leagueId: string, driverId: string, message?: string): void {
|
|
||||||
const existing = getMembership(leagueId, driverId);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRequest = joinRequests.find(r => r.leagueId === leagueId && r.driverId === driverId);
|
|
||||||
if (existingRequest) {
|
|
||||||
throw new Error('Join request already pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
joinRequests.push({
|
|
||||||
id: `request-${Date.now()}`,
|
|
||||||
leagueId,
|
|
||||||
driverId,
|
|
||||||
requestedAt: new Date(),
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave a league
|
|
||||||
export function leaveLeague(leagueId: string, driverId: string): void {
|
|
||||||
const membership = getMembership(leagueId, driverId);
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('Not a member of this league');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role === 'owner') {
|
|
||||||
throw new Error('League owner cannot leave. Transfer ownership first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
memberships = memberships.filter(m => !(m.leagueId === leagueId && m.driverId === driverId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approve join request
|
|
||||||
export function approveJoinRequest(requestId: string): void {
|
|
||||||
const request = joinRequests.find(r => r.id === requestId);
|
|
||||||
if (!request) {
|
|
||||||
throw new Error('Join request not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
memberships.push({
|
|
||||||
leagueId: request.leagueId,
|
|
||||||
driverId: request.driverId,
|
|
||||||
role: 'member',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
joinRequests = joinRequests.filter(r => r.id !== requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject join request
|
|
||||||
export function rejectJoinRequest(requestId: string): void {
|
|
||||||
joinRequests = joinRequests.filter(r => r.id !== requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove member (admin action)
|
|
||||||
export function removeMember(leagueId: string, driverId: string, removedBy: string): void {
|
|
||||||
const removerMembership = getMembership(leagueId, removedBy);
|
|
||||||
if (!removerMembership || (removerMembership.role !== 'owner' && removerMembership.role !== 'admin')) {
|
|
||||||
throw new Error('Only owners and admins can remove members');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMembership = getMembership(leagueId, driverId);
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new Error('Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetMembership.role === 'owner') {
|
|
||||||
throw new Error('Cannot remove league owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
memberships = memberships.filter(m => !(m.leagueId === leagueId && m.driverId === driverId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update member role
|
|
||||||
export function updateMemberRole(
|
|
||||||
leagueId: string,
|
|
||||||
driverId: string,
|
|
||||||
newRole: MembershipRole,
|
|
||||||
updatedBy: string
|
|
||||||
): void {
|
|
||||||
const updaterMembership = getMembership(leagueId, updatedBy);
|
|
||||||
if (!updaterMembership || updaterMembership.role !== 'owner') {
|
|
||||||
throw new Error('Only league owner can change roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMembership = getMembership(leagueId, driverId);
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new Error('Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newRole === 'owner') {
|
|
||||||
throw new Error('Use transfer ownership to change owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
memberships = memberships.map(m =>
|
|
||||||
m.leagueId === leagueId && m.driverId === driverId
|
|
||||||
? { ...m, role: newRole }
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if driver is owner or admin
|
|
||||||
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
|
|
||||||
const membership = getMembership(leagueId, driverId);
|
|
||||||
return membership?.role === 'owner' || membership?.role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current driver ID
|
|
||||||
export function getCurrentDriverId(): string {
|
|
||||||
return CURRENT_DRIVER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on module load
|
|
||||||
initializeMembershipData();
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory race registration data for alpha prototype
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getMembership } from './memberships';
|
|
||||||
|
|
||||||
import { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
|
||||||
|
|
||||||
// In-memory storage (Set for quick lookups)
|
|
||||||
const registrations = new Map<string, Set<string>>(); // raceId -> Set of driverIds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate registration key for storage
|
|
||||||
*/
|
|
||||||
function getRegistrationKey(raceId: string, driverId: string): string {
|
|
||||||
return `${raceId}:${driverId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if driver is registered for a race
|
|
||||||
*/
|
|
||||||
export function isRegistered(raceId: string, driverId: string): boolean {
|
|
||||||
const raceRegistrations = registrations.get(raceId);
|
|
||||||
return raceRegistrations ? raceRegistrations.has(driverId) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all registered drivers for a race
|
|
||||||
*/
|
|
||||||
export function getRegisteredDrivers(raceId: string): string[] {
|
|
||||||
const raceRegistrations = registrations.get(raceId);
|
|
||||||
return raceRegistrations ? Array.from(raceRegistrations) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get registration count for a race
|
|
||||||
*/
|
|
||||||
export function getRegistrationCount(raceId: string): number {
|
|
||||||
const raceRegistrations = registrations.get(raceId);
|
|
||||||
return raceRegistrations ? raceRegistrations.size : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register driver for a race
|
|
||||||
* Validates league membership before registering
|
|
||||||
*/
|
|
||||||
export function registerForRace(
|
|
||||||
raceId: string,
|
|
||||||
driverId: string,
|
|
||||||
leagueId: string
|
|
||||||
): void {
|
|
||||||
// Check if already registered
|
|
||||||
if (isRegistered(raceId, driverId)) {
|
|
||||||
throw new Error('Already registered for this race');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate league membership
|
|
||||||
const membership = getMembership(leagueId, driverId);
|
|
||||||
if (!membership || membership.status !== 'active') {
|
|
||||||
throw new Error('Must be an active league member to register for races');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add registration
|
|
||||||
if (!registrations.has(raceId)) {
|
|
||||||
registrations.set(raceId, new Set());
|
|
||||||
}
|
|
||||||
registrations.get(raceId)!.add(driverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Withdraw from a race
|
|
||||||
*/
|
|
||||||
export function withdrawFromRace(raceId: string, driverId: string): void {
|
|
||||||
const raceRegistrations = registrations.get(raceId);
|
|
||||||
if (!raceRegistrations || !raceRegistrations.has(driverId)) {
|
|
||||||
throw new Error('Not registered for this race');
|
|
||||||
}
|
|
||||||
|
|
||||||
raceRegistrations.delete(driverId);
|
|
||||||
|
|
||||||
// Clean up empty sets
|
|
||||||
if (raceRegistrations.size === 0) {
|
|
||||||
registrations.delete(raceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all races a driver is registered for
|
|
||||||
*/
|
|
||||||
export function getDriverRegistrations(driverId: string): string[] {
|
|
||||||
const raceIds: string[] = [];
|
|
||||||
|
|
||||||
for (const [raceId, driverSet] of registrations.entries()) {
|
|
||||||
if (driverSet.has(driverId)) {
|
|
||||||
raceIds.push(raceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return raceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all registrations for a race (e.g., when race is cancelled)
|
|
||||||
*/
|
|
||||||
export function clearRaceRegistrations(raceId: string): void {
|
|
||||||
registrations.delete(raceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize with seed data
|
|
||||||
*/
|
|
||||||
export function initializeRegistrationData(): void {
|
|
||||||
registrations.clear();
|
|
||||||
|
|
||||||
// Add some initial registrations for testing
|
|
||||||
// Race 2 (Spa-Francorchamps - upcoming)
|
|
||||||
registerForRace('race-2', 'driver-1', 'league-1');
|
|
||||||
registerForRace('race-2', 'driver-2', 'league-1');
|
|
||||||
registerForRace('race-2', 'driver-3', 'league-1');
|
|
||||||
|
|
||||||
// Race 3 (Nürburgring GP - upcoming)
|
|
||||||
registerForRace('race-3', 'driver-1', 'league-1');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on module load
|
|
||||||
initializeRegistrationData();
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory team data for alpha prototype
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Team,
|
|
||||||
TeamMembership,
|
|
||||||
TeamJoinRequest,
|
|
||||||
TeamRole,
|
|
||||||
TeamMembershipStatus,
|
|
||||||
} from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
|
|
||||||
// In-memory storage
|
|
||||||
let teams: Team[] = [];
|
|
||||||
let teamMemberships: TeamMembership[] = [];
|
|
||||||
let teamJoinRequests: TeamJoinRequest[] = [];
|
|
||||||
|
|
||||||
// Current driver ID (matches di-container)
|
|
||||||
const CURRENT_DRIVER_ID = 'driver-1';
|
|
||||||
|
|
||||||
// Initialize with seed data
|
|
||||||
export function initializeTeamData() {
|
|
||||||
teams = [
|
|
||||||
{
|
|
||||||
id: 'team-1',
|
|
||||||
name: 'Apex Racing',
|
|
||||||
tag: 'APEX',
|
|
||||||
description: 'Professional GT3 racing team competing at the highest level',
|
|
||||||
ownerId: CURRENT_DRIVER_ID,
|
|
||||||
leagues: ['league-1'],
|
|
||||||
createdAt: new Date('2024-01-20'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'team-2',
|
|
||||||
name: 'Speed Demons',
|
|
||||||
tag: 'SPDM',
|
|
||||||
description: 'Fast and furious racing with a competitive edge',
|
|
||||||
ownerId: 'driver-2',
|
|
||||||
leagues: ['league-1'],
|
|
||||||
createdAt: new Date('2024-02-01'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'team-3',
|
|
||||||
name: 'Weekend Warriors',
|
|
||||||
tag: 'WKND',
|
|
||||||
description: 'Casual but competitive weekend racing',
|
|
||||||
ownerId: 'driver-3',
|
|
||||||
leagues: ['league-1'],
|
|
||||||
createdAt: new Date('2024-02-10'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
teamMemberships = [
|
|
||||||
{
|
|
||||||
teamId: 'team-1',
|
|
||||||
driverId: CURRENT_DRIVER_ID,
|
|
||||||
role: 'owner',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-01-20'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: 'team-2',
|
|
||||||
driverId: 'driver-2',
|
|
||||||
role: 'owner',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-02-01'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: 'team-3',
|
|
||||||
driverId: 'driver-3',
|
|
||||||
role: 'owner',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date('2024-02-10'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
teamJoinRequests = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all teams
|
|
||||||
export function getAllTeams(): Team[] {
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get team by ID
|
|
||||||
export function getTeam(teamId: string): Team | null {
|
|
||||||
return teams.find(t => t.id === teamId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get team membership for a driver
|
|
||||||
export function getTeamMembership(teamId: string, driverId: string): TeamMembership | null {
|
|
||||||
return teamMemberships.find(m => m.teamId === teamId && m.driverId === driverId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get driver's team
|
|
||||||
export function getDriverTeam(driverId: string): { team: Team; membership: TeamMembership } | null {
|
|
||||||
const membership = teamMemberships.find(m => m.driverId === driverId && m.status === 'active');
|
|
||||||
if (!membership) return null;
|
|
||||||
|
|
||||||
const team = getTeam(membership.teamId);
|
|
||||||
if (!team) return null;
|
|
||||||
|
|
||||||
return { team, membership };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all members for a team
|
|
||||||
export function getTeamMembers(teamId: string): TeamMembership[] {
|
|
||||||
return teamMemberships.filter(m => m.teamId === teamId && m.status === 'active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get pending join requests for a team
|
|
||||||
export function getTeamJoinRequests(teamId: string): TeamJoinRequest[] {
|
|
||||||
return teamJoinRequests.filter(r => r.teamId === teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new team
|
|
||||||
export function createTeam(
|
|
||||||
name: string,
|
|
||||||
tag: string,
|
|
||||||
description: string,
|
|
||||||
ownerId: string,
|
|
||||||
leagues: string[]
|
|
||||||
): Team {
|
|
||||||
// Check if driver already has a team
|
|
||||||
const existingTeam = getDriverTeam(ownerId);
|
|
||||||
if (existingTeam) {
|
|
||||||
throw new Error('Driver already belongs to a team');
|
|
||||||
}
|
|
||||||
|
|
||||||
const team: Team = {
|
|
||||||
id: `team-${Date.now()}`,
|
|
||||||
name,
|
|
||||||
tag,
|
|
||||||
description,
|
|
||||||
ownerId,
|
|
||||||
leagues,
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
teams.push(team);
|
|
||||||
|
|
||||||
// Auto-assign creator as owner
|
|
||||||
teamMemberships.push({
|
|
||||||
teamId: team.id,
|
|
||||||
driverId: ownerId,
|
|
||||||
role: 'owner',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return team;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join a team
|
|
||||||
export function joinTeam(teamId: string, driverId: string): void {
|
|
||||||
const existingTeam = getDriverTeam(driverId);
|
|
||||||
if (existingTeam) {
|
|
||||||
throw new Error('Driver already belongs to a team');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getTeamMembership(teamId, driverId);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamMemberships.push({
|
|
||||||
teamId,
|
|
||||||
driverId,
|
|
||||||
role: 'driver',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request to join a team
|
|
||||||
export function requestToJoinTeam(teamId: string, driverId: string, message?: string): void {
|
|
||||||
const existingTeam = getDriverTeam(driverId);
|
|
||||||
if (existingTeam) {
|
|
||||||
throw new Error('Driver already belongs to a team');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getTeamMembership(teamId, driverId);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRequest = teamJoinRequests.find(r => r.teamId === teamId && r.driverId === driverId);
|
|
||||||
if (existingRequest) {
|
|
||||||
throw new Error('Join request already pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamJoinRequests.push({
|
|
||||||
id: `team-request-${Date.now()}`,
|
|
||||||
teamId,
|
|
||||||
driverId,
|
|
||||||
requestedAt: new Date(),
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave a team
|
|
||||||
export function leaveTeam(teamId: string, driverId: string): void {
|
|
||||||
const membership = getTeamMembership(teamId, driverId);
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('Not a member of this team');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role === 'owner') {
|
|
||||||
throw new Error('Team owner cannot leave. Transfer ownership or disband team first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamMemberships = teamMemberships.filter(m => !(m.teamId === teamId && m.driverId === driverId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approve join request
|
|
||||||
export function approveTeamJoinRequest(requestId: string): void {
|
|
||||||
const request = teamJoinRequests.find(r => r.id === requestId);
|
|
||||||
if (!request) {
|
|
||||||
throw new Error('Join request not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamMemberships.push({
|
|
||||||
teamId: request.teamId,
|
|
||||||
driverId: request.driverId,
|
|
||||||
role: 'driver',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
teamJoinRequests = teamJoinRequests.filter(r => r.id !== requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject join request
|
|
||||||
export function rejectTeamJoinRequest(requestId: string): void {
|
|
||||||
teamJoinRequests = teamJoinRequests.filter(r => r.id !== requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove member (admin action)
|
|
||||||
export function removeTeamMember(teamId: string, driverId: string, removedBy: string): void {
|
|
||||||
const removerMembership = getTeamMembership(teamId, removedBy);
|
|
||||||
if (!removerMembership || (removerMembership.role !== 'owner' && removerMembership.role !== 'manager')) {
|
|
||||||
throw new Error('Only owners and managers can remove members');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMembership = getTeamMembership(teamId, driverId);
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new Error('Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetMembership.role === 'owner') {
|
|
||||||
throw new Error('Cannot remove team owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamMemberships = teamMemberships.filter(m => !(m.teamId === teamId && m.driverId === driverId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update member role
|
|
||||||
export function updateTeamMemberRole(
|
|
||||||
teamId: string,
|
|
||||||
driverId: string,
|
|
||||||
newRole: TeamRole,
|
|
||||||
updatedBy: string
|
|
||||||
): void {
|
|
||||||
const updaterMembership = getTeamMembership(teamId, updatedBy);
|
|
||||||
if (!updaterMembership || updaterMembership.role !== 'owner') {
|
|
||||||
throw new Error('Only team owner can change roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMembership = getTeamMembership(teamId, driverId);
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new Error('Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newRole === 'owner') {
|
|
||||||
throw new Error('Use transfer ownership to change owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
teamMemberships = teamMemberships.map(m =>
|
|
||||||
m.teamId === teamId && m.driverId === driverId
|
|
||||||
? { ...m, role: newRole }
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if driver is owner or manager
|
|
||||||
export function isTeamOwnerOrManager(teamId: string, driverId: string): boolean {
|
|
||||||
const membership = getTeamMembership(teamId, driverId);
|
|
||||||
return membership?.role === 'owner' || membership?.role === 'manager';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current driver ID
|
|
||||||
export function getCurrentDriverId(): string {
|
|
||||||
return CURRENT_DRIVER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update team info
|
|
||||||
export function updateTeam(
|
|
||||||
teamId: string,
|
|
||||||
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>,
|
|
||||||
updatedBy: string
|
|
||||||
): void {
|
|
||||||
if (!isTeamOwnerOrManager(teamId, updatedBy)) {
|
|
||||||
throw new Error('Only owners and managers can update team info');
|
|
||||||
}
|
|
||||||
|
|
||||||
teams = teams.map(t =>
|
|
||||||
t.id === teamId
|
|
||||||
? { ...t, ...updates }
|
|
||||||
: t
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on module load
|
|
||||||
initializeTeamData();
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type {
|
||||||
|
TeamMembership,
|
||||||
|
TeamMembershipStatus,
|
||||||
|
TeamRole,
|
||||||
|
TeamJoinRequest,
|
||||||
|
} from '../../domain/entities/Team';
|
||||||
|
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class ApproveTeamJoinRequestUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> {
|
||||||
|
const { requestId } = command;
|
||||||
|
|
||||||
|
// There is no repository method to look up a single request by ID,
|
||||||
|
// so we rely on the repository implementation to surface all relevant
|
||||||
|
// requests via getJoinRequests and search by ID here.
|
||||||
|
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
|
||||||
|
// For the in-memory fake used in tests, the teamId argument is ignored
|
||||||
|
// and all requests are returned.
|
||||||
|
'' as string,
|
||||||
|
);
|
||||||
|
const request = allRequests.find((r) => r.id === requestId);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Join request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership: TeamMembership = {
|
||||||
|
teamId: request.teamId,
|
||||||
|
driverId: request.driverId,
|
||||||
|
role: 'driver' as TeamRole,
|
||||||
|
status: 'active' as TeamMembershipStatus,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.membershipRepository.saveMembership(membership);
|
||||||
|
await this.membershipRepository.removeJoinRequest(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/racing/application/use-cases/CreateTeamUseCase.ts
Normal file
54
packages/racing/application/use-cases/CreateTeamUseCase.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type {
|
||||||
|
Team,
|
||||||
|
TeamMembership,
|
||||||
|
TeamMembershipStatus,
|
||||||
|
TeamRole,
|
||||||
|
} from '../../domain/entities/Team';
|
||||||
|
import type {
|
||||||
|
CreateTeamCommandDTO,
|
||||||
|
CreateTeamResultDTO,
|
||||||
|
} from '../dto/CreateTeamCommandDTO';
|
||||||
|
|
||||||
|
export class CreateTeamUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly teamRepository: ITeamRepository,
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> {
|
||||||
|
const { name, tag, description, ownerId, leagues } = command;
|
||||||
|
|
||||||
|
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new Error('Driver already belongs to a team');
|
||||||
|
}
|
||||||
|
|
||||||
|
const team: Team = {
|
||||||
|
id: `team-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
tag,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
leagues,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdTeam = await this.teamRepository.create(team);
|
||||||
|
|
||||||
|
const membership: TeamMembership = {
|
||||||
|
teamId: createdTeam.id,
|
||||||
|
driverId: ownerId,
|
||||||
|
role: 'owner' as TeamRole,
|
||||||
|
status: 'active' as TeamMembershipStatus,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.membershipRepository.saveMembership(membership);
|
||||||
|
|
||||||
|
return { team: createdTeam };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/racing/application/use-cases/GetAllTeamsQuery.ts
Normal file
13
packages/racing/application/use-cases/GetAllTeamsQuery.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
|
import type { GetAllTeamsQueryResultDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class GetAllTeamsQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly teamRepository: ITeamRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(): Promise<GetAllTeamsQueryResultDTO> {
|
||||||
|
const teams = await this.teamRepository.findAll();
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/racing/application/use-cases/GetDriverTeamQuery.ts
Normal file
29
packages/racing/application/use-cases/GetDriverTeamQuery.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type {
|
||||||
|
GetDriverTeamQueryParamsDTO,
|
||||||
|
GetDriverTeamQueryResultDTO,
|
||||||
|
} from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class GetDriverTeamQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly teamRepository: ITeamRepository,
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: GetDriverTeamQueryParamsDTO): Promise<GetDriverTeamQueryResultDTO | null> {
|
||||||
|
const { driverId } = params;
|
||||||
|
|
||||||
|
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
|
||||||
|
if (!membership) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await this.teamRepository.findById(membership.teamId);
|
||||||
|
if (!team) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { team, membership };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
|
||||||
|
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query object returning registered driver IDs for a race.
|
||||||
|
* Mirrors legacy getRegisteredDrivers behavior.
|
||||||
|
*/
|
||||||
|
export class GetRaceRegistrationsQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly registrationRepository: IRaceRegistrationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<string[]> {
|
||||||
|
const { raceId } = params;
|
||||||
|
return this.registrationRepository.getRegisteredDrivers(raceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/racing/application/use-cases/GetTeamDetailsQuery.ts
Normal file
26
packages/racing/application/use-cases/GetTeamDetailsQuery.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type {
|
||||||
|
GetTeamDetailsQueryParamsDTO,
|
||||||
|
GetTeamDetailsQueryResultDTO,
|
||||||
|
} from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class GetTeamDetailsQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly teamRepository: ITeamRepository,
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: GetTeamDetailsQueryParamsDTO): Promise<GetTeamDetailsQueryResultDTO> {
|
||||||
|
const { teamId, driverId } = params;
|
||||||
|
|
||||||
|
const team = await this.teamRepository.findById(teamId);
|
||||||
|
if (!team) {
|
||||||
|
throw new Error('Team not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await this.membershipRepository.getMembership(teamId, driverId);
|
||||||
|
|
||||||
|
return { team, membership };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type { TeamJoinRequest } from '../../domain/entities/Team';
|
||||||
|
import type { GetTeamJoinRequestsQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class GetTeamJoinRequestsQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: GetTeamJoinRequestsQueryParamsDTO): Promise<TeamJoinRequest[]> {
|
||||||
|
const { teamId } = params;
|
||||||
|
return this.membershipRepository.getJoinRequests(teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/racing/application/use-cases/GetTeamMembersQuery.ts
Normal file
14
packages/racing/application/use-cases/GetTeamMembersQuery.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type { TeamMembership } from '../../domain/entities/Team';
|
||||||
|
import type { GetTeamMembersQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class GetTeamMembersQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: GetTeamMembersQueryParamsDTO): Promise<TeamMembership[]> {
|
||||||
|
const { teamId } = params;
|
||||||
|
return this.membershipRepository.getTeamMembers(teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
|
||||||
|
import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only wrapper around IRaceRegistrationRepository.isRegistered.
|
||||||
|
* Mirrors legacy isRegistered behavior.
|
||||||
|
*/
|
||||||
|
export class IsDriverRegisteredForRaceQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly registrationRepository: IRaceRegistrationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<boolean> {
|
||||||
|
const { raceId, driverId } = params;
|
||||||
|
return this.registrationRepository.isRegistered(raceId, driverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,7 @@ import type {
|
|||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||||
|
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
|
||||||
export interface JoinLeagueCommand {
|
|
||||||
leagueId: string;
|
|
||||||
driverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JoinLeagueUseCase {
|
export class JoinLeagueUseCase {
|
||||||
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
|
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
|
||||||
@@ -22,7 +18,7 @@ export class JoinLeagueUseCase {
|
|||||||
* - Throws when membership already exists for this league/driver.
|
* - Throws when membership already exists for this league/driver.
|
||||||
* - Creates a new active membership with role "member" and current timestamp.
|
* - Creates a new active membership with role "member" and current timestamp.
|
||||||
*/
|
*/
|
||||||
async execute(command: JoinLeagueCommand): Promise<LeagueMembership> {
|
async execute(command: JoinLeagueCommandDTO): Promise<LeagueMembership> {
|
||||||
const { leagueId, driverId } = command;
|
const { leagueId, driverId } = command;
|
||||||
|
|
||||||
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
|
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
|
||||||
|
|||||||
46
packages/racing/application/use-cases/JoinTeamUseCase.ts
Normal file
46
packages/racing/application/use-cases/JoinTeamUseCase.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type {
|
||||||
|
TeamMembership,
|
||||||
|
TeamMembershipStatus,
|
||||||
|
TeamRole,
|
||||||
|
} from '../../domain/entities/Team';
|
||||||
|
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class JoinTeamUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly teamRepository: ITeamRepository,
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: JoinTeamCommandDTO): Promise<void> {
|
||||||
|
const { teamId, driverId } = command;
|
||||||
|
|
||||||
|
const existingActive = await this.membershipRepository.getActiveMembershipForDriver(
|
||||||
|
driverId,
|
||||||
|
);
|
||||||
|
if (existingActive) {
|
||||||
|
throw new Error('Driver already belongs to a team');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new Error('Already a member or have a pending request');
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await this.teamRepository.findById(teamId);
|
||||||
|
if (!team) {
|
||||||
|
throw new Error('Team not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership: TeamMembership = {
|
||||||
|
teamId,
|
||||||
|
driverId,
|
||||||
|
role: 'driver' as TeamRole,
|
||||||
|
status: 'active' as TeamMembershipStatus,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.membershipRepository.saveMembership(membership);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/racing/application/use-cases/LeaveTeamUseCase.ts
Normal file
25
packages/racing/application/use-cases/LeaveTeamUseCase.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
import type { LeaveTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
|
|
||||||
|
export class LeaveTeamUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: LeaveTeamCommandDTO): Promise<void> {
|
||||||
|
const { teamId, driverId } = command;
|
||||||
|
|
||||||
|
const membership = await this.membershipRepository.getMembership(teamId, driverId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error('Not a member of this team');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role === 'owner') {
|
||||||
|
throw new Error(
|
||||||
|
'Team owner cannot leave. Transfer ownership or disband team first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.membershipRepository.removeMembership(teamId, driverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
|
|
||||||
|
|
||||||
export interface IsDriverRegisteredForRaceQueryParams {
|
|
||||||
raceId: string;
|
|
||||||
driverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IsDriverRegisteredForRaceQuery {
|
|
||||||
constructor(
|
|
||||||
private readonly registrationRepository: IRaceRegistrationRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only wrapper around IRaceRegistrationRepository.isRegistered.
|
|
||||||
* Mirrors legacy isRegistered behavior.
|
|
||||||
*/
|
|
||||||
async execute(params: IsDriverRegisteredForRaceQueryParams): Promise<boolean> {
|
|
||||||
const { raceId, driverId } = params;
|
|
||||||
return this.registrationRepository.isRegistered(raceId, driverId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRaceRegistrationsQueryParams {
|
|
||||||
raceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query object returning registered driver IDs for a race.
|
|
||||||
* Mirrors legacy getRegisteredDrivers behavior.
|
|
||||||
*/
|
|
||||||
export class GetRaceRegistrationsQuery {
|
|
||||||
constructor(
|
|
||||||
private readonly registrationRepository: IRaceRegistrationRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(params: GetRaceRegistrationsQueryParams): Promise<string[]> {
|
|
||||||
const { raceId } = params;
|
|
||||||
return this.registrationRepository.getRegisteredDrivers(raceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user