docs
This commit is contained in:
@@ -1,649 +0,0 @@
|
||||
# Clean Architecture plan for GridPilot (NestJS‑focused)
|
||||
|
||||
This document defines the **target architecture** and **rules** for GridPilot.
|
||||
It is written as a **developer-facing contract**: what goes where, what is allowed, and what is forbidden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural goals
|
||||
|
||||
* Strict Clean Architecture (dependency rule enforced)
|
||||
* Domain-first design (DDD-inspired)
|
||||
* Frameworks are delivery mechanisms, not architecture
|
||||
* Business logic is isolated from persistence, UI, and infrastructure
|
||||
* Explicit composition roots
|
||||
* High testability without mocking the domain
|
||||
|
||||
---
|
||||
|
||||
## 2. High-level structure
|
||||
|
||||
```
|
||||
/apps
|
||||
/api # NestJS API (delivery mechanism)
|
||||
/website # Next.js website (delivery mechanism)
|
||||
/electron # Electron app (delivery mechanism)
|
||||
|
||||
/core # Business rules (framework-agnostic)
|
||||
/analytics
|
||||
/automation
|
||||
/identity
|
||||
/media
|
||||
/notifications
|
||||
/racing
|
||||
/social
|
||||
/shared
|
||||
|
||||
/adapters # Technical implementations (details)
|
||||
/persistence
|
||||
/typeorm
|
||||
/inmemory
|
||||
/auth
|
||||
/media
|
||||
/notifications
|
||||
/logging
|
||||
|
||||
/testing # Test-only code (never shipped)
|
||||
/contexts
|
||||
/factories
|
||||
/builders
|
||||
/fakes
|
||||
/fixtures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency rule (non-negotiable)
|
||||
|
||||
Dependencies **must only point inward**:
|
||||
|
||||
```
|
||||
apps → adapters → core
|
||||
```
|
||||
|
||||
Forbidden:
|
||||
|
||||
* `core` importing from `adapters`
|
||||
* `core` importing from `apps`
|
||||
* domain entities importing ORM, NestJS, or framework code
|
||||
|
||||
---
|
||||
|
||||
## 4. Core rules
|
||||
|
||||
The Core contains **only business rules**.
|
||||
|
||||
### Core MAY contain:
|
||||
|
||||
* Domain entities
|
||||
* Value objects
|
||||
* Domain services
|
||||
* Domain events
|
||||
* Repository interfaces
|
||||
* Application use cases
|
||||
* Application-level ports
|
||||
|
||||
### Core MUST NOT contain:
|
||||
|
||||
* ORM entities
|
||||
* Persistence implementations
|
||||
* In-memory repositories
|
||||
* NestJS decorators
|
||||
* TypeORM decorators
|
||||
* HTTP / GraphQL / IPC concerns
|
||||
* Faker, demo data, seeds
|
||||
|
||||
---
|
||||
|
||||
## 5. Domain entities
|
||||
|
||||
Domain entities:
|
||||
|
||||
* Represent business concepts
|
||||
* Enforce invariants
|
||||
* Contain behavior
|
||||
* Are immutable or controlled via methods
|
||||
|
||||
Example characteristics:
|
||||
|
||||
* Private constructors
|
||||
* Static factory methods
|
||||
* Explicit validation
|
||||
* Value objects for identity
|
||||
|
||||
Domain entities **must never**:
|
||||
|
||||
* Be decorated with `@Entity`, `@Column`, etc.
|
||||
* Be reused as persistence models
|
||||
* Know how they are stored
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence entities (ORM)
|
||||
|
||||
Persistence entities live in adapters and are **data-only**.
|
||||
|
||||
```
|
||||
/adapters/persistence/typeorm/<context>
|
||||
- PageViewOrmEntity.ts
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* No business logic
|
||||
* No validation
|
||||
* No behavior
|
||||
* Flat data structures
|
||||
|
||||
ORM entities are **not domain entities**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mapping (anti-corruption layer)
|
||||
|
||||
Mapping between domain and persistence is explicit and isolated.
|
||||
|
||||
```
|
||||
/adapters/persistence/typeorm/<context>
|
||||
- PageViewMapper.ts
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Domain ↔ ORM mapping only happens in adapters
|
||||
* Mappers are pure functions
|
||||
* Boilerplate is acceptable and expected
|
||||
|
||||
---
|
||||
|
||||
## 8. Repositories
|
||||
|
||||
### Core
|
||||
|
||||
```
|
||||
/core/<context>/domain/repositories
|
||||
- IPageViewRepository.ts
|
||||
```
|
||||
|
||||
Only interfaces.
|
||||
|
||||
### Adapters
|
||||
|
||||
```
|
||||
/adapters/persistence/typeorm/<context>
|
||||
- PageViewTypeOrmRepository.ts
|
||||
|
||||
/adapters/persistence/inmemory/<context>
|
||||
- InMemoryPageViewRepository.ts
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Repositories translate between domain entities and storage models
|
||||
* Repositories implement core interfaces
|
||||
* Repositories hide all persistence details from the core
|
||||
|
||||
---
|
||||
|
||||
## 9. In-memory repositories
|
||||
|
||||
In-memory repositories are **test adapters**, not core infrastructure.
|
||||
|
||||
Rules:
|
||||
|
||||
* Never placed in `/core`
|
||||
* Allowed only in `/adapters/persistence/inmemory`
|
||||
* Used for unit tests and integration tests
|
||||
* Must behave like real repositories
|
||||
|
||||
---
|
||||
|
||||
## 10. NestJS API (`/apps/api`)
|
||||
|
||||
The NestJS API is a **delivery mechanism**.
|
||||
|
||||
### Responsibilities:
|
||||
|
||||
* Controllers
|
||||
* DTOs
|
||||
* Request validation
|
||||
* Dependency injection
|
||||
* Adapter selection (prod vs test)
|
||||
|
||||
### Forbidden:
|
||||
|
||||
* Business logic
|
||||
* Domain rules
|
||||
* Persistence logic
|
||||
|
||||
NestJS modules are **composition roots**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Dependency injection
|
||||
|
||||
DI is configured **only in apps**.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
provide: IPageViewRepository
|
||||
useClass: PageViewTypeOrmRepository
|
||||
```
|
||||
|
||||
Switching implementations (e.g. InMemory vs TypeORM) happens **outside the core**.
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing strategy
|
||||
|
||||
Tests are **clients of the system**, not part of the architecture.
|
||||
|
||||
### Core principles
|
||||
|
||||
* `/tests` contains test cases
|
||||
* `/testing` contains **test support only** (helpers, factories, fixtures)
|
||||
* Production code must never import from `/testing`
|
||||
|
||||
---
|
||||
|
||||
## 13. Test support structure (`/testing`)
|
||||
|
||||
The `/testing` folder is a **test-support library**, not a layer.
|
||||
It follows a single, strict structure:
|
||||
|
||||
```
|
||||
/testing
|
||||
/fixtures # Static reference data (NO logic, NO faker)
|
||||
/factories # Domain object factories (faker allowed ONLY here)
|
||||
/fakes # Fake implementations of external ports
|
||||
/helpers # Generic test utilities (time, ids, assertions)
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Rules for test support
|
||||
|
||||
The project uses **factories only**.
|
||||
There is **no separate concept of fixtures**.
|
||||
|
||||
All test data is produced via factories.
|
||||
This avoids duplication, drift, and ambiguity.
|
||||
|
||||
---
|
||||
|
||||
### Factories (the single source of test data)
|
||||
|
||||
Factories are the **only approved way** to create test data.
|
||||
|
||||
Rules:
|
||||
|
||||
* Factories create **valid domain objects** or **valid DTOs** (boundary only)
|
||||
* One factory per concept
|
||||
* Stateless
|
||||
* One export per file
|
||||
* File name === exported symbol
|
||||
* Faker / randomness allowed here
|
||||
|
||||
Factories must **NOT**:
|
||||
|
||||
* Use repositories
|
||||
* Perform IO
|
||||
* Contain assertions
|
||||
* Contain domain logic
|
||||
|
||||
---
|
||||
|
||||
### Fakes
|
||||
|
||||
Fakes replace **external systems only**.
|
||||
|
||||
Rules:
|
||||
|
||||
* Implement application ports
|
||||
* Explicit behavior
|
||||
* One export per file
|
||||
* No domain logic
|
||||
|
||||
---
|
||||
|
||||
### Helpers
|
||||
|
||||
Helpers are **pure utilities**.
|
||||
|
||||
Rules:
|
||||
|
||||
* No domain knowledge
|
||||
* No adapters
|
||||
* No randomness
|
||||
|
||||
---
|
||||
|
||||
### Factories
|
||||
|
||||
* Create **valid domain objects**
|
||||
* Stateless
|
||||
* Deterministic unless randomness is required
|
||||
* Faker is allowed **only here**
|
||||
|
||||
Factories **must NOT**:
|
||||
|
||||
* Use repositories
|
||||
* Use DTOs
|
||||
* Perform IO
|
||||
|
||||
---
|
||||
|
||||
### Fakes
|
||||
|
||||
* Implement application ports
|
||||
* Replace external systems (payments, email, auth, etc.)
|
||||
* Explicit, controllable behavior
|
||||
|
||||
---
|
||||
|
||||
### Helpers
|
||||
|
||||
* Pure utilities
|
||||
* No domain logic
|
||||
* No adapters
|
||||
|
||||
---
|
||||
|
||||
## 15. DTO usage in tests (strict rule)
|
||||
|
||||
DTOs are **boundary objects**, not domain concepts.
|
||||
|
||||
Rules:
|
||||
|
||||
* Domain tests: **never** use DTOs
|
||||
* Domain factories: **never** use DTOs
|
||||
* Use case tests: DTOs allowed **only if the use case explicitly accepts a DTO**
|
||||
* API / controller tests: DTOs are expected and allowed
|
||||
|
||||
If DTOs are needed in tests, they must be created explicitly, e.g.:
|
||||
|
||||
```
|
||||
/testing/dto-factories
|
||||
```
|
||||
|
||||
This makes boundary tests obvious and prevents accidental coupling.
|
||||
|
||||
---
|
||||
|
||||
## 16. Concrete pseudo examples (authoritative)
|
||||
|
||||
The following examples show the **only intended usage**, aligned with **Screaming Architecture** and **one-export-per-file**.
|
||||
|
||||
Naming and placement are part of the rule.
|
||||
|
||||
---
|
||||
|
||||
### Naming rules (strict)
|
||||
|
||||
* One file = one export
|
||||
* File name === exported symbol
|
||||
* PascalCase only
|
||||
* No suffixes like `.factory`, `.fixture`, `.fake`
|
||||
|
||||
Correct:
|
||||
|
||||
```
|
||||
PageViewFactory.ts
|
||||
PageViewFixture.ts
|
||||
FakePaymentGateway.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example: Domain entity
|
||||
|
||||
```ts
|
||||
// core/analytics/domain/entities/PageView.ts
|
||||
export class PageView {
|
||||
static create(props) {}
|
||||
isMeaningfulView(): boolean {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example: Fixture (static data)
|
||||
|
||||
```ts
|
||||
// testing/analytics/fixtures/PageViewFixture.ts
|
||||
export const PageViewFixture = {
|
||||
id: 'pv-1',
|
||||
entityType: 'league',
|
||||
entityId: 'l-1',
|
||||
visitorType: 'guest',
|
||||
sessionId: 's-1',
|
||||
};
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* Plain object
|
||||
* One export
|
||||
* No logic
|
||||
* No faker
|
||||
|
||||
---
|
||||
|
||||
### Example: Factory (domain object creation)
|
||||
|
||||
```ts
|
||||
// testing/analytics/factories/PageViewFactory.ts
|
||||
export class PageViewFactory {
|
||||
static create(overrides = {}) {
|
||||
return PageView.create({
|
||||
id: randomId(),
|
||||
entityType: 'league',
|
||||
entityId: randomId(),
|
||||
visitorType: 'guest',
|
||||
sessionId: randomId(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* Produces domain entity
|
||||
* Faker / randomness allowed here
|
||||
* One export per file
|
||||
* No DTOs
|
||||
* No repositories
|
||||
|
||||
---
|
||||
|
||||
### Example: Fake (external port)
|
||||
|
||||
```ts
|
||||
// testing/racing/fakes/FakePaymentGateway.ts
|
||||
export class FakePaymentGateway implements PaymentGatewayPort {
|
||||
charge() {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* Implements a port
|
||||
* Simulates external system
|
||||
* One export per file
|
||||
* No domain logic
|
||||
|
||||
---
|
||||
|
||||
### Example: Domain test
|
||||
|
||||
```ts
|
||||
// tests/analytics/PageView.spec.ts
|
||||
const pageView = PageViewFactory.create({ durationMs: 6000 });
|
||||
|
||||
expect(pageView.isMeaningfulView()).toBe(true);
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* Talks domain language
|
||||
* No DTOs
|
||||
* No framework
|
||||
|
||||
---
|
||||
|
||||
### Example: Use case test (DTO allowed only if required)
|
||||
|
||||
```ts
|
||||
// tests/analytics/RecordPageView.spec.ts
|
||||
const command = RecordPageViewCommandDTO.create({ ... });
|
||||
|
||||
useCase.execute(command);
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* DTO only used because it is the explicit input
|
||||
* Boundary is under test
|
||||
|
||||
---
|
||||
|
||||
### Example: API test
|
||||
|
||||
```ts
|
||||
// tests/api/analytics.e2e.spec.ts
|
||||
POST /api/page-views
|
||||
body = RecordPageViewCommandDTO
|
||||
```
|
||||
|
||||
Rules demonstrated:
|
||||
|
||||
* DTOs expected
|
||||
* HTTP boundary
|
||||
* Full stack
|
||||
|
||||
---
|
||||
|
||||
## 17. Migration vs Bootstrap (production data)
|
||||
|
||||
Certain data exists **not because of business rules**, but because the application must be operable.
|
||||
This is **not a testing concern** and **not a domain concern**.
|
||||
|
||||
---
|
||||
|
||||
### Migrations
|
||||
|
||||
**Purpose:**
|
||||
|
||||
* Create or evolve the database schema
|
||||
|
||||
Characteristics:
|
||||
|
||||
* Tables, columns, indices, constraints
|
||||
* Versioned
|
||||
* Deterministic
|
||||
* Idempotent per version
|
||||
|
||||
Rules:
|
||||
|
||||
* No domain logic
|
||||
* No factories
|
||||
* No faker
|
||||
* No test helpers
|
||||
* No business decisions
|
||||
|
||||
Location:
|
||||
|
||||
```
|
||||
/adapters/persistence/migrations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bootstrap (initial application data)
|
||||
|
||||
**Purpose:**
|
||||
|
||||
* Ensure the system is usable after first start
|
||||
|
||||
Examples:
|
||||
|
||||
* Initial admin user
|
||||
* System roles
|
||||
* Required system configuration entries
|
||||
|
||||
Characteristics:
|
||||
|
||||
* Runs at application startup
|
||||
* Idempotent
|
||||
* Environment-driven
|
||||
|
||||
Rules:
|
||||
|
||||
* Must NOT live in core
|
||||
* Must NOT live in testing
|
||||
* Must NOT live in repositories
|
||||
* Must use application use cases
|
||||
|
||||
Location:
|
||||
|
||||
```
|
||||
/adapters/bootstrap
|
||||
```
|
||||
|
||||
Example (pseudo):
|
||||
|
||||
```ts
|
||||
class EnsureInitialAdminUser {
|
||||
run(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
The application (e.g. NestJS) decides **when** this runs.
|
||||
Adapters decide **how** it runs.
|
||||
|
||||
---
|
||||
|
||||
### Reference Data (registries exposed via API)
|
||||
|
||||
Some “data” is neither **domain behavior** nor **UI config**. It is **authoritative reference data** that outer layers need (website, API DTOs), but it is not a domain entity itself.
|
||||
|
||||
Examples:
|
||||
|
||||
* Scoring preset registries (preset identifiers, point systems, drop-week policies, default timing presets)
|
||||
* Catalog-like lists needed by the UI to render forms safely without duplicating domain semantics
|
||||
|
||||
Rules:
|
||||
|
||||
* The website (presentation) must never own or hardcode domain catalogs.
|
||||
* Core owns invariants, enumerations, and validation rules. If a rule is an invariant, it belongs in Core.
|
||||
* Adapters may own **reference registries** when they are not domain entities, and when they represent curated “known lists” used for configuration and defaults.
|
||||
* The API must expose reference registries via HTTP so the website remains a pure API consumer (no importing adapters or core).
|
||||
* UI-only concerns (labels, icons, colors, layout defaults) remain in the website.
|
||||
|
||||
---
|
||||
|
||||
### Hard separation
|
||||
|
||||
| Concern | Purpose | Location |
|
||||
| -------------- | ---------------------------------------------- | ------------------------------- |
|
||||
| Migration | Schema | adapters/persistence/migrations |
|
||||
| Bootstrap | Required start data | adapters/bootstrap |
|
||||
| Reference Data | Authoritative registries consumed via API | adapters/bootstrap + apps/api |
|
||||
| Factory | Test data | testing |
|
||||
|
||||
---
|
||||
|
||||
## 18. Final enforcement rules
|
||||
|
||||
* Domain knows nothing about migrations or bootstrap
|
||||
* Repositories never auto-create data
|
||||
* Factories are test-only
|
||||
* Bootstrap is explicit and idempotent
|
||||
* Migrations and bootstrap are never mixed
|
||||
|
||||
This section is authoritative.
|
||||
@@ -1,153 +0,0 @@
|
||||
# 2025-12-15 Clean Architecture Migration Plan for GridPilot
|
||||
|
||||
## 1. Current State Summary
|
||||
|
||||
- **Core**: Modular structure with domains (`analytics`, `automation`, `identity`, `media`, `notifications`, `racing`, `social`, `shared`). Contains pure domain elements (`entities`, `value-objects`, `repositories` interfaces, `use-cases`, `ports`). Violations: Infrastructure leaks (`infrastructure/repositories/InMemory*` in `analytics`, `automation`), adapters in `automation/infrastructure/adapters`.
|
||||
- **Apps/API**: NestJS app with internal layers (`application/services`, `infrastructure/database`, `presentation/controllers`). Mostly placeholder (`hello.service`, `analytics.controller`). [`apps/api/src/infrastructure/database/database.module.ts`](apps/api/src/infrastructure/database/database.module.ts) suggests persistence setup but minimal implementation.
|
||||
- **Apps/Website**: `lib/` holds DI (`di-container.ts`, `di-tokens.ts`), auth (`InMemoryAuthService.ts`), presenters (40+ `presenters/*.ts` – business logic mixed with presentation), utils. Presenters likely contain domain logic violating isolation.
|
||||
- **Apps/Companion**: DI setup (`di-container.ts`), IPC handlers, renderer components.
|
||||
- **Testing**: Comprehensive `/tests/` (`unit/`, `integration/`, `e2e/`, `smoke/`, `bdd/`) testing domains/use-cases. `/testing-support/` with fakers, demo adapters – partial match but not structured as `/testing/factories/fakes/fixtures/helpers`.
|
||||
- **Persistence**: Sparse; no ORM entities, migrations, or real repos visible. In-memory only in core.
|
||||
- **Overall**: Partial Clean Arch adoption in core domains; infra/UI mixing; no top-level `/adapters`; testing scattered.
|
||||
|
||||
## 2. Gap Analysis per Layer/Domain
|
||||
|
||||
### Core (Business Rules)
|
||||
- **Compliant**: Domain entities/VO/services/use-cases/ports in `core/*/domain|application`.
|
||||
- **Violations**: Infra in core (`InMemory repos`, adapters). Must extract to `/adapters`.
|
||||
- **Domains**: `analytics` (page views), `automation` (racing wizards), `media` (avatars), `notifications`, `racing` (leagues/races), `social` – all need infra purge.
|
||||
|
||||
### Adapters (Details)
|
||||
- **Gap**: No `/adapters/` top-level. Persistence (typeorm/inmemory), auth/media/notifications/logging missing structure.
|
||||
- **Action**: Move core infra; implement mappers, ORM entities, real repos.
|
||||
|
||||
### Apps (Delivery)
|
||||
- **Backend (API)**: Internal layers ok as composition root but move domain deps to core refs.
|
||||
- **Frontend (Website/Companion)**: Presenters/DI in `lib/` – extract business to core use-cases, keep UI presenters thin.
|
||||
- **Violations**: Domain logic in presenters (`LeagueStandingsPresenter.ts` etc.).
|
||||
|
||||
### Testing
|
||||
- **Gap**: `/tests/unit/domain/` mixes support/cases. `/testing-support/` unstructured.
|
||||
- **Action**: Pure cases in `/tests`, support to `/testing/factories` etc. No DTOs in domain tests.
|
||||
|
||||
### Persistence/Bootstrap
|
||||
- **Gap**: No `/adapters/persistence/migrations`, `/adapters/bootstrap`. Database.module placeholder.
|
||||
|
||||
## 3. Phased Migration Plan
|
||||
|
||||
Focus: Backend/core first (Phases 1-7), then frontend (8-10), testing (11), verification (12).
|
||||
|
||||
### Phase 1: Directory Setup & Skeleton
|
||||
- **Scope**: Create dirs: `/adapters/persistence/{typeorm,inmemory}`, `/adapters/{auth,media,notifications,logging}`, `/adapters/bootstrap`, `/testing/{fixtures,factories,fakes,helpers}`, `/adapters/persistence/migrations`.
|
||||
- **Focus**: Backend structure.
|
||||
- **Prerequisites**: None.
|
||||
- **Outcome**: Empty skeleton matching target.
|
||||
- **Risks**: Path conflicts (low).
|
||||
|
||||
### Phase 2: Purge Core Infrastructure – In-Memory Repos
|
||||
- **Scope**: Move `core/*/infrastructure/repositories/InMemory*` → `/adapters/persistence/inmemory/*/InMemory*.ts`. Delete source dirs. Update core imports to ports only.
|
||||
- **Focus**: Core purity (analytics, automation).
|
||||
- **Prerequisites**: Phase 1.
|
||||
- **Outcome**: Core interfaces-only repos.
|
||||
- **Risks**: Import breaks in tests/use-cases.
|
||||
|
||||
### Phase 3: Extract Automation Adapters
|
||||
- **Scope**: Move `core/automation/infrastructure/adapters/*` → `/adapters/*` (e.g., logging → `/adapters/logging`). Update paths.
|
||||
- **Focus**: Backend adapters.
|
||||
- **Prerequisites**: Phase 2.
|
||||
- **Outcome**: Centralized adapters.
|
||||
- **Risks**: DI token mismatches.
|
||||
|
||||
### Phase 4: Implement Persistence Mappers & ORM Skeleton
|
||||
- **Scope**: Create `/adapters/persistence/typeorm/*/OrmEntity.ts`, `Mapper.ts` for key domains (racing League/Race, analytics PageView). Stub repos.
|
||||
- **Focus**: Backend persistence.
|
||||
- **Prerequisites**: Phase 1-3.
|
||||
- **Outcome**: ORM ready for data migration.
|
||||
- **Risks**: Schema design errors.
|
||||
|
||||
### Phase 5: Migrate API Layers to Core Dependencies
|
||||
- **Scope**: Refactor `apps/api/src/application/*`, `presentation/*` to use core use-cases/ports. Move `infrastructure/database/*` → `/adapters/persistence/typeorm`.
|
||||
- **Focus**: Backend API.
|
||||
- **Prerequisites**: Phase 4.
|
||||
- **Outcome**: API as thin delivery.
|
||||
- **Risks**: Controller logic loss.
|
||||
|
||||
### Phase 6: Add Migrations & Bootstrap
|
||||
- **Scope**: Create `/adapters/persistence/migrations/*.ts` for schema. `/adapters/bootstrap/EnsureInitialData.ts` using use-cases.
|
||||
- **Focus**: Backend operability.
|
||||
- **Prerequisites**: Phase 4.
|
||||
- **Outcome**: Schema + bootstrap.
|
||||
- **Risks**: Data loss on migrate.
|
||||
|
||||
### Phase 7: Core Domain Refinements
|
||||
- **Scope**: Enforce entities immutable/private ctor/factory methods across domains. Add domain events if missing.
|
||||
- **Focus**: Backend core.
|
||||
- **Prerequisites**: Phase 1-6.
|
||||
- **Outcome**: Strict domain.
|
||||
- **Risks**: Breaking changes in use-cases.
|
||||
|
||||
### Phase 8: Refactor Website Presenters
|
||||
- **Scope**: Extract business logic from `apps/website/lib/presenters/*` to core use cases/domain services; keep thin presenters in `apps/website/lib/presenters` as UI adapters implementing core output ports.
|
||||
- **Focus**: Frontend/backend split.
|
||||
- **Prerequisites**: Phase 7.
|
||||
- **Outcome**: Business logic extracted to core; thin UI presenters remain in app.
|
||||
- **Risks**: UI logic entanglement.
|
||||
- **Presenter Note**: Core defines presenter interfaces (output ports) if needed; implementations (adapters) stay in apps/website.
|
||||
|
||||
### Phase 9: Clean Frontend DI & Auth
|
||||
- **Scope**: Remove `apps/website/lib/{di-*,auth/*}` or thin to delivery. Use core ports.
|
||||
- **Focus**: Frontend.
|
||||
- **Prerequisites**: Phase 8.
|
||||
- **Outcome**: Apps pure delivery.
|
||||
- **Risks**: Auth breakage.
|
||||
|
||||
### Phase 10: Companion Alignment
|
||||
- **Scope**: Align `apps/companion/{main/renderer/lib}` to use core/adapters. Extract any domain.
|
||||
- **Focus**: Frontend.
|
||||
- **Prerequisites**: Phase 9.
|
||||
- **Outcome**: Consistent delivery.
|
||||
- **Risks**: Electron IPC changes.
|
||||
|
||||
### Phase 11: Testing Restructure
|
||||
- **Scope**: Move test support to `/testing/factories/*Factory.ts`, `fakes/*Fake.ts`. Pure cases in `/tests`. Enforce no DTOs in domain tests.
|
||||
- **Focus**: Testing.
|
||||
- **Prerequisites**: All prior.
|
||||
- **Outcome**: Strict testing.
|
||||
- **Risks**: Test failures.
|
||||
|
||||
### Phase 12: Verification & Enforcement
|
||||
- **Scope**: Add ESLint/dependency-cruiser rules for layers. Run full test suite, e2e.
|
||||
- **Focus**: All.
|
||||
- **Prerequisites**: Phase 11.
|
||||
- **Outcome**: Enforced architecture.
|
||||
- **Risks**: Rule false positives.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Phase 1: Dirs] --> B[Phase 2: Core Purge]
|
||||
B --> C[Phase 3: Adapters]
|
||||
C --> D[Phase 4: ORM]
|
||||
D --> E[Phase 5: API]
|
||||
E --> F[Phase 6: Migrate/Bootstrap]
|
||||
F --> G[Phase 7: Domain Refine]
|
||||
G --> H[Phase 8: Refactor Website Presenters]
|
||||
H --> I[Phase 9: Frontend Clean]
|
||||
I --> J[Phase 10: Companion]
|
||||
J --> K[Phase 11: Testing]
|
||||
K --> L[Phase 12: Verify]
|
||||
```
|
||||
|
||||
## 4. TODO Checklist
|
||||
|
||||
- [ ] Phase 1: Directory Setup & Core Skeleton
|
||||
- [ ] Phase 2: Purge Core Infrastructure – In-Memory Repos
|
||||
- [ ] Phase 3: Extract Automation Adapters
|
||||
- [ ] Phase 4: Implement Persistence Mappers & ORM Skeleton
|
||||
- [ ] Phase 5: Migrate API Layers to Core Dependencies
|
||||
- [ ] Phase 6: Add Migrations & Bootstrap
|
||||
- [ ] Phase 7: Core Domain Refinements
|
||||
- [ ] Phase 8: Refactor Website Presenters (logic to core use cases; thin adapters in app)
|
||||
- [ ] Phase 9: Clean Frontend DI & Auth
|
||||
- [ ] Phase 10: Companion Alignment
|
||||
- [ ] Phase 11: Testing Restructure
|
||||
- [ ] Phase 12: Verification & Enforcement
|
||||
@@ -1,332 +0,0 @@
|
||||
# Website Architecture Refactoring Plan (CORRECTED)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
I've identified **75+ violations** where `apps/website` directly imports from core domain, use cases, or repositories. This plan provides the **CORRECT** architecture with:
|
||||
- **One presenter = One transformation = One `present()` method**
|
||||
- **Pure constructor injection**
|
||||
- **Stateless presenters**
|
||||
- **No singleton exports**
|
||||
- **No redundant index.ts files**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Correct Presenter Architecture
|
||||
|
||||
### The Rule: One Presenter = One Transformation
|
||||
|
||||
Each presenter has **exactly one responsibility**: transform one specific DTO into one specific ViewModel.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Single present() method, one purpose
|
||||
export class RaceDetailPresenter {
|
||||
present(dto: RaceDetailDto): RaceDetailViewModel {
|
||||
return new RaceDetailViewModel(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Another presenter for different transformation
|
||||
export class RaceResultsDetailPresenter {
|
||||
present(dto: RaceResultsDetailDto, currentUserId?: string): RaceResultsDetailViewModel {
|
||||
return new RaceResultsDetailViewModel(dto, currentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Yet another presenter for different transformation
|
||||
export class RaceWithSOFPresenter {
|
||||
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
|
||||
return {
|
||||
id: dto.raceId,
|
||||
strengthOfField: dto.strengthOfField,
|
||||
registeredCount: dto.registeredCount,
|
||||
// ... pure transformation
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Correct Service Layer Design
|
||||
|
||||
### Service with Multiple Focused Presenters
|
||||
|
||||
```typescript
|
||||
// ✅ apps/website/lib/services/races/RaceService.ts
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { RaceDetailPresenter } from '@/lib/presenters/RaceDetailPresenter';
|
||||
import { RacesPagePresenter } from '@/lib/presenters/RacesPagePresenter';
|
||||
import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
|
||||
import type { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
||||
|
||||
/**
|
||||
* Race Service
|
||||
*
|
||||
* Orchestrates race operations. Each operation uses its own focused presenter
|
||||
* for a specific DTO-to-ViewModel transformation.
|
||||
*/
|
||||
export class RaceService {
|
||||
constructor(
|
||||
private readonly apiClient: RacesApiClient,
|
||||
private readonly raceDetailPresenter: RaceDetailPresenter,
|
||||
private readonly racesPagePresenter: RacesPagePresenter
|
||||
) {}
|
||||
|
||||
async getRaceDetail(raceId: string, driverId: string): Promise<RaceDetailViewModel> {
|
||||
const dto = await this.apiClient.getDetail(raceId, driverId);
|
||||
return this.raceDetailPresenter.present(dto);
|
||||
}
|
||||
|
||||
async getRacesPageData(): Promise<RacesPageViewModel> {
|
||||
const dto = await this.apiClient.getPageData();
|
||||
return this.racesPagePresenter.present(dto);
|
||||
}
|
||||
|
||||
async completeRace(raceId: string): Promise<void> {
|
||||
await this.apiClient.complete(raceId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Race Results Service with Multiple Presenters
|
||||
|
||||
```typescript
|
||||
// ✅ apps/website/lib/services/races/RaceResultsService.ts
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
|
||||
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
|
||||
import { ImportRaceResultsSummaryPresenter } from '@/lib/presenters/ImportRaceResultsSummaryPresenter';
|
||||
import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
|
||||
import type { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
|
||||
import type { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel';
|
||||
|
||||
export class RaceResultsService {
|
||||
constructor(
|
||||
private readonly apiClient: RacesApiClient,
|
||||
private readonly resultsDetailPresenter: RaceResultsDetailPresenter,
|
||||
private readonly sofPresenter: RaceWithSOFPresenter,
|
||||
private readonly importSummaryPresenter: ImportRaceResultsSummaryPresenter
|
||||
) {}
|
||||
|
||||
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
||||
const dto = await this.apiClient.getResultsDetail(raceId);
|
||||
return this.resultsDetailPresenter.present(dto, currentUserId);
|
||||
}
|
||||
|
||||
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
|
||||
const dto = await this.apiClient.getWithSOF(raceId);
|
||||
return this.sofPresenter.present(dto);
|
||||
}
|
||||
|
||||
async importResults(raceId: string, input: any): Promise<ImportRaceResultsSummaryViewModel> {
|
||||
const dto = await this.apiClient.importResults(raceId, input);
|
||||
return this.importSummaryPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Correct Presenter Organization
|
||||
|
||||
```
|
||||
apps/website/lib/presenters/
|
||||
├── RaceDetailPresenter.ts # RaceDetailDto -> RaceDetailViewModel
|
||||
├── RacesPagePresenter.ts # RacesPageDataDto -> RacesPageViewModel
|
||||
├── RaceResultsDetailPresenter.ts # RaceResultsDetailDto -> RaceResultsDetailViewModel
|
||||
├── RaceWithSOFPresenter.ts # RaceWithSOFDto -> RaceWithSOFViewModel
|
||||
├── ImportRaceResultsSummaryPresenter.ts # ImportRaceResultsSummaryDto -> ImportRaceResultsSummaryViewModel
|
||||
├── LeagueDetailPresenter.ts # LeagueDetailDto -> LeagueDetailViewModel
|
||||
├── LeagueStandingsPresenter.ts # LeagueStandingsDto -> LeagueStandingsViewModel
|
||||
├── LeagueStatsPresenter.ts # LeagueStatsDto -> LeagueStatsViewModel
|
||||
├── DriverProfilePresenter.ts # DriverProfileDto -> DriverProfileViewModel
|
||||
├── DriverLeaderboardPresenter.ts # DriverLeaderboardDto -> DriverLeaderboardViewModel
|
||||
├── TeamDetailsPresenter.ts # TeamDetailsDto -> TeamDetailsViewModel
|
||||
├── TeamMembersPresenter.ts # TeamMembersDto -> TeamMembersViewModel
|
||||
└── ...
|
||||
|
||||
NO index.ts files
|
||||
NO multi-method presenters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Correct ServiceFactory
|
||||
|
||||
```typescript
|
||||
// ✅ apps/website/lib/services/ServiceFactory.ts
|
||||
import { ApiClient } from '@/lib/api';
|
||||
import { RaceService } from '@/lib/services/races/RaceService';
|
||||
import { RaceResultsService } from '@/lib/services/races/RaceResultsService';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||
|
||||
// Race presenters
|
||||
import { RaceDetailPresenter } from '@/lib/presenters/RaceDetailPresenter';
|
||||
import { RacesPagePresenter } from '@/lib/presenters/RacesPagePresenter';
|
||||
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
|
||||
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
|
||||
import { ImportRaceResultsSummaryPresenter } from '@/lib/presenters/ImportRaceResultsSummaryPresenter';
|
||||
|
||||
// League presenters
|
||||
import { LeagueDetailPresenter } from '@/lib/presenters/LeagueDetailPresenter';
|
||||
import { LeagueStandingsPresenter } from '@/lib/presenters/LeagueStandingsPresenter';
|
||||
import { LeagueStatsPresenter } from '@/lib/presenters/LeagueStatsPresenter';
|
||||
|
||||
// Driver presenters
|
||||
import { DriverProfilePresenter } from '@/lib/presenters/DriverProfilePresenter';
|
||||
import { DriverLeaderboardPresenter } from '@/lib/presenters/DriverLeaderboardPresenter';
|
||||
|
||||
// Team presenters
|
||||
import { TeamDetailsPresenter } from '@/lib/presenters/TeamDetailsPresenter';
|
||||
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Service Factory - Composition Root
|
||||
*
|
||||
* Creates and wires up all services with their dependencies.
|
||||
* Each service gets all the presenters it needs.
|
||||
*/
|
||||
export class ServiceFactory {
|
||||
private static apiClient = new ApiClient(API_BASE_URL);
|
||||
|
||||
// Race presenters
|
||||
private static raceDetailPresenter = new RaceDetailPresenter();
|
||||
private static racesPagePresenter = new RacesPagePresenter();
|
||||
private static raceResultsDetailPresenter = new RaceResultsDetailPresenter();
|
||||
private static raceWithSOFPresenter = new RaceWithSOFPresenter();
|
||||
private static importRaceResultsSummaryPresenter = new ImportRaceResultsSummaryPresenter();
|
||||
|
||||
// League presenters
|
||||
private static leagueDetailPresenter = new LeagueDetailPresenter();
|
||||
private static leagueStandingsPresenter = new LeagueStandingsPresenter();
|
||||
private static leagueStatsPresenter = new LeagueStatsPresenter();
|
||||
|
||||
// Driver presenters
|
||||
private static driverProfilePresenter = new DriverProfilePresenter();
|
||||
private static driverLeaderboardPresenter = new DriverLeaderboardPresenter();
|
||||
|
||||
// Team presenters
|
||||
private static teamDetailsPresenter = new TeamDetailsPresenter();
|
||||
private static teamMembersPresenter = new TeamMembersPresenter();
|
||||
|
||||
static createRaceService(): RaceService {
|
||||
return new RaceService(
|
||||
this.apiClient.races,
|
||||
this.raceDetailPresenter,
|
||||
this.racesPagePresenter
|
||||
);
|
||||
}
|
||||
|
||||
static createRaceResultsService(): RaceResultsService {
|
||||
return new RaceResultsService(
|
||||
this.apiClient.races,
|
||||
this.raceResultsDetailPresenter,
|
||||
this.raceWithSOFPresenter,
|
||||
this.importRaceResultsSummaryPresenter
|
||||
);
|
||||
}
|
||||
|
||||
static createLeagueService(): LeagueService {
|
||||
return new LeagueService(
|
||||
this.apiClient.leagues,
|
||||
this.leagueDetailPresenter,
|
||||
this.leagueStandingsPresenter,
|
||||
this.leagueStatsPresenter
|
||||
);
|
||||
}
|
||||
|
||||
static createDriverService(): DriverService {
|
||||
return new DriverService(
|
||||
this.apiClient.drivers,
|
||||
this.driverProfilePresenter,
|
||||
this.driverLeaderboardPresenter
|
||||
);
|
||||
}
|
||||
|
||||
static createTeamService(): TeamService {
|
||||
return new TeamService(
|
||||
this.apiClient.teams,
|
||||
this.teamDetailsPresenter,
|
||||
this.teamMembersPresenter
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Complete Example: Race Domain
|
||||
|
||||
### File Structure
|
||||
```
|
||||
apps/website/lib/
|
||||
├── services/races/
|
||||
│ ├── RaceService.ts
|
||||
│ └── RaceResultsService.ts
|
||||
├── presenters/
|
||||
│ ├── RaceDetailPresenter.ts
|
||||
│ ├── RacesPagePresenter.ts
|
||||
│ ├── RaceResultsDetailPresenter.ts
|
||||
│ ├── RaceWithSOFPresenter.ts
|
||||
│ └── ImportRaceResultsSummaryPresenter.ts
|
||||
├── view-models/
|
||||
│ ├── RaceDetailViewModel.ts
|
||||
│ ├── RacesPageViewModel.ts
|
||||
│ ├── RaceResultsDetailViewModel.ts
|
||||
│ ├── RaceWithSOFViewModel.ts
|
||||
│ └── ImportRaceResultsSummaryViewModel.ts
|
||||
└── dtos/
|
||||
├── RaceDetailDto.ts
|
||||
├── RacesPageDataDto.ts
|
||||
├── RaceResultsDetailDto.ts
|
||||
├── RaceWithSOFDto.ts
|
||||
└── ImportRaceResultsSummaryDto.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Architectural Principles
|
||||
|
||||
1. **One Presenter = One Transformation** - Each presenter has exactly one `present()` method
|
||||
2. **Service = Orchestrator** - Services coordinate API calls and presenter transformations
|
||||
3. **Multiple Presenters per Service** - Services inject all presenters they need
|
||||
4. **No Presenter Reuse Across Domains** - Each domain has its own presenters
|
||||
5. **ServiceFactory = Composition Root** - Single place to wire everything up
|
||||
6. **Stateless Presenters** - No instance state, pure transformations
|
||||
7. **Constructor Injection** - All dependencies explicit
|
||||
|
||||
---
|
||||
|
||||
## 📋 Migration Checklist
|
||||
|
||||
### For Each Presenter:
|
||||
- [ ] Verify exactly one `present()` method
|
||||
- [ ] Verify stateless (no instance properties)
|
||||
- [ ] Verify pure transformation (no side effects)
|
||||
- [ ] Remove any functional wrapper exports
|
||||
|
||||
### For Each Service:
|
||||
- [ ] Identify all DTO-to-ViewModel transformations needed
|
||||
- [ ] Inject all required presenters via constructor
|
||||
- [ ] Each method calls appropriate presenter
|
||||
- [ ] No presenter logic in service
|
||||
|
||||
### For ServiceFactory:
|
||||
- [ ] Create shared presenter instances
|
||||
- [ ] Wire presenters to services via constructor
|
||||
- [ ] One factory method per service
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
1. ✅ Each presenter has exactly one `present()` method
|
||||
2. ✅ Services inject all presenters they need
|
||||
3. ✅ No multi-method presenters
|
||||
4. ✅ ServiceFactory wires everything correctly
|
||||
5. ✅ Zero direct imports from `@core`
|
||||
6. ✅ All tests pass
|
||||
@@ -1,125 +0,0 @@
|
||||
# Plan: Switch API persistence from InMemory to Postgres (TypeORM), keep InMemory for tests
|
||||
|
||||
Timestamp: 2025-12-28T20:17:49Z
|
||||
Scope: **Racing bounded context first**, dev switchable between InMemory and Postgres, tests keep forcing InMemory.
|
||||
|
||||
## Goals
|
||||
- Make it **easy to switch** API persistence in dev via [`getApiPersistence()`](apps/api/src/env.ts:33) + [`process.env.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7).
|
||||
- Default dev flow supports Postgres via Docker, but does **not** force it.
|
||||
- Keep InMemory persistence intact and default for tests (already used in tests like [`process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'`](apps/api/src/domain/bootstrap/BootstrapSeed.http.test.ts:17)).
|
||||
- Implement Postgres/TypeORM persistence for **racing repositories** currently provided by [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72).
|
||||
- Provide **minimal idempotent seed** for Postgres so UI works (analogous to [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)).
|
||||
|
||||
## Current state (what’s wired today)
|
||||
- Env toggle exists: [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
- Postgres wiring exists but incomplete: [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) with `entities` commented and `synchronize` enabled outside production ([`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18)).
|
||||
- Feature modules still hard-import in-memory racing persistence:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:8)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:8)
|
||||
- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:2)
|
||||
- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:2)
|
||||
- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:2)
|
||||
- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:2)
|
||||
- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:2)
|
||||
- [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:11)
|
||||
- Dev compose currently forces InMemory even when `.env` provides `DATABASE_URL`:
|
||||
- Forced: [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36)
|
||||
- `.env` hints inference from `DATABASE_URL`: [`DATABASE_URL=postgres://...`](.env.development.example:20)
|
||||
|
||||
## High-level approach
|
||||
1. Introduce a **persistence boundary module** for racing that selects implementation based on [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
2. Implement a Postgres/TypeORM module for racing repos (same tokens as in-memory).
|
||||
3. Update racing-dependent API feature modules to import the boundary module (not the in-memory module).
|
||||
4. Add minimal Postgres seed (dev-only, idempotent).
|
||||
5. Fix dev compose to not hard-force InMemory.
|
||||
6. Verify with lint/types/tests.
|
||||
|
||||
## Milestones (execution order)
|
||||
### M1 — Create racing persistence boundary (switch point)
|
||||
- Add [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1)
|
||||
- `imports`: choose one of:
|
||||
- [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72)
|
||||
- New [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1)
|
||||
- `exports`: re-export the chosen module’s tokens, so downstream modules remain unchanged.
|
||||
|
||||
Acceptance:
|
||||
- No feature module directly imports [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) anymore.
|
||||
|
||||
### M2 — Add Postgres racing persistence module (skeleton)
|
||||
- Add [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1)
|
||||
- Provides **the same tokens** defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51):
|
||||
- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52), [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53), etc.
|
||||
- Uses `TypeOrmModule.forFeature([...entities])`.
|
||||
- Must be compatible with DB root config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
|
||||
Acceptance:
|
||||
- API can start with `GRIDPILOT_API_PERSISTENCE=postgres` and resolve racing repository providers (even if repo methods are initially stubbed during iteration).
|
||||
|
||||
### M3 — Implement first working slice (League + Season + Membership + Race as needed)
|
||||
- Implement ORM entities + mappers (ORM entities are not domain objects; follow clean architecture boundary from [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)).
|
||||
- Implement TypeORM repositories for the minimal feature set used by:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:7)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:7)
|
||||
|
||||
Strategy:
|
||||
- Start from the endpoints exercised by existing HTTP tests that currently force InMemory (e.g. league schedule/roster tests), but run them in InMemory first; then add a small Postgres-specific smoke test later if needed.
|
||||
|
||||
Acceptance:
|
||||
- Core use cases depending on racing repos function against Postgres in dev.
|
||||
|
||||
### M4 — Rewire feature modules to boundary module
|
||||
Replace imports of [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) with [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) in:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:1)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:1)
|
||||
- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:1)
|
||||
- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:1)
|
||||
- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:1)
|
||||
- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:1)
|
||||
- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:1)
|
||||
- plus adjust [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) (see M5).
|
||||
|
||||
Acceptance:
|
||||
- Switching env var changes racing persistence without touching module imports.
|
||||
|
||||
### M5 — Minimal idempotent Postgres seed (dev UX)
|
||||
- Extend bootstrap so Postgres mode can seed minimal data when DB is empty.
|
||||
- Current bootstrap behavior only seeds racing data for InMemory: [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37).
|
||||
- Update logic to also seed for Postgres when:
|
||||
- dev mode (non-prod), and
|
||||
- tables empty (e.g., count leagues/drivers), and
|
||||
- bootstrap enabled via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
|
||||
Implementation note:
|
||||
- Seed code should remain adapter-level (reuse [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)) but use repos from the active persistence module.
|
||||
|
||||
Acceptance:
|
||||
- `docker compose -f docker-compose.dev.yml up` + `GRIDPILOT_API_PERSISTENCE=postgres` results in a usable UI without manual DB setup.
|
||||
|
||||
### M6 — Dev compose/env ergonomics
|
||||
- Remove hard-coded forcing of InMemory in dev compose:
|
||||
- Change/remove [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36)
|
||||
- Prefer `.env.development` control, consistent with `.env example` guidance ([`DATABASE_URL`](.env.development.example:20)).
|
||||
|
||||
Acceptance:
|
||||
- Devs can switch by editing `.env.development` or setting env override.
|
||||
|
||||
### M7 — Verification gate
|
||||
Run:
|
||||
- `eslint`
|
||||
- `tsc`
|
||||
- tests
|
||||
|
||||
Commands live in workspace scripts; start from package-level scripts as applicable (e.g. API tests via [`npm run test`](apps/api/package.json:10)).
|
||||
|
||||
Acceptance:
|
||||
- No lint errors, no TypeScript errors, green tests (with default tests still using InMemory).
|
||||
|
||||
## Out of scope (this pass)
|
||||
- Social persistence (stays InMemory for now).
|
||||
- Full migration system (placeholder remains, e.g. [`up()`](adapters/persistence/migrations/001_initial_schema.ts:5)).
|
||||
- Production-ready DB lifecycle (migrations, RLS, etc.).
|
||||
|
||||
## Risks / watchouts
|
||||
- Provider scoping: racing repos are exported tokens; boundary module must avoid creating competing instances.
|
||||
- Entity design: ORM entities must not leak into core (enforce boundary per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)).
|
||||
- Bootstrap: ensure Postgres seed is idempotent and doesn’t run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)).
|
||||
@@ -1,419 +0,0 @@
|
||||
# Plan: Switch Racing persistence from InMemory to Postgres (TypeORM) while keeping InMemory for tests
|
||||
|
||||
Timestamp: 2025-12-28T20:46:00Z
|
||||
Scope: Racing bounded context persistence only (no Social/Identity/Media/Payments persistence changes)
|
||||
|
||||
This plan is intentionally implementation-ready (what files to add, what to wire, what tests to write first) while keeping scope controlled: a minimal vertical slice that makes one meaningful League/Race workflow work in dev Postgres, but does not attempt to implement every Racing repository at once.
|
||||
|
||||
---
|
||||
|
||||
## 0) Context (current state, must preserve)
|
||||
|
||||
- Persistence toggle is already defined at [`getApiPersistence()`](apps/api/src/env.ts:33) and typed in [`ProcessEnv`](apps/api/src/env.d.ts:3).
|
||||
- DB bootstrap exists as Nest module: [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), with non-prod schema sync enabled via [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18).
|
||||
- Racing repository tokens are currently defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) and are used as Nest provider tokens.
|
||||
- Persistence boundary already exists and selects between in-memory and Postgres: [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1).
|
||||
- Postgres wiring for Racing is currently placeholder-only: [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51).
|
||||
- Clean Architecture rules to enforce (no ORM leakage into Core):
|
||||
- [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1)
|
||||
- [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:1)
|
||||
- File placement rules via [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:1)
|
||||
|
||||
---
|
||||
|
||||
## 1) Goal and non-goals
|
||||
|
||||
### Goal
|
||||
Enable Racing persistence via Postgres/TypeORM for development runtime (selected via [`getApiPersistence()`](apps/api/src/env.ts:33)), while keeping default test runs using in-memory persistence (to keep CI fast and deterministic).
|
||||
|
||||
### Non-goals (explicit scope control)
|
||||
- No migration framework rollout, no production migration story beyond the existing non-prod `synchronize` behavior in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18).
|
||||
- No broad refactors to use cases, DTOs, controllers, or domain modeling.
|
||||
- No implementation of non-Racing bounded contexts (Social/Identity/Media/etc).
|
||||
- No attempt to “finish all Racing repositories” in first pass; we will do a minimal vertical slice first.
|
||||
|
||||
---
|
||||
|
||||
## 2) Proposed adapter folder/file layout (TypeORM under `adapters/`)
|
||||
|
||||
This follows the existing adapter organization shown in [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:41) and the repo’s existing Racing adapter grouping under `adapters/racing/persistence/inmemory`.
|
||||
|
||||
Create a parallel `typeorm` tree for Racing persistence:
|
||||
|
||||
- `adapters/racing/persistence/typeorm/README.md`
|
||||
- `adapters/racing/persistence/typeorm/entities/`
|
||||
- `LeagueOrmEntity.ts`
|
||||
- `SeasonOrmEntity.ts`
|
||||
- `LeagueScoringConfigOrmEntity.ts`
|
||||
- `RaceOrmEntity.ts`
|
||||
- `LeagueMembershipOrmEntity.ts`
|
||||
- `adapters/racing/persistence/typeorm/mappers/`
|
||||
- `LeagueOrmMapper.ts`
|
||||
- `SeasonOrmMapper.ts`
|
||||
- `LeagueScoringConfigOrmMapper.ts`
|
||||
- `RaceOrmMapper.ts`
|
||||
- `LeagueMembershipOrmMapper.ts`
|
||||
- `adapters/racing/persistence/typeorm/repositories/`
|
||||
- `TypeOrmLeagueRepository.ts`
|
||||
- `TypeOrmSeasonRepository.ts`
|
||||
- `TypeOrmLeagueScoringConfigRepository.ts`
|
||||
- `TypeOrmRaceRepository.ts`
|
||||
- `TypeOrmLeagueMembershipRepository.ts`
|
||||
- `adapters/racing/persistence/typeorm/testing/` (test-only helpers; never imported by prod code)
|
||||
- `createTypeOrmTestDataSource.ts`
|
||||
- `truncateRacingTables.ts`
|
||||
|
||||
Notes:
|
||||
- Do not add any `index.ts` barrel files due to the lint restriction in [`.eslintrc.json`](.eslintrc.json:36).
|
||||
- All mapping logic must live in adapters (never in Core), per [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24).
|
||||
|
||||
---
|
||||
|
||||
## 3) Minimal vertical slice (useful and controlled)
|
||||
|
||||
### 3.1 “Meaningful workflow” target
|
||||
Implement the smallest set that supports this end-to-end workflow in Postgres dev:
|
||||
|
||||
1) Create a League (creates Season + scoring config) via the API call handled by the service method [`LeagueService.createLeague()`](apps/api/src/domain/league/LeagueService.ts:773) which uses [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56).
|
||||
2) Fetch that League’s schedule (or races list) via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33), used by [`LeagueService.getLeagueSchedule()`](apps/api/src/domain/league/LeagueService.ts:614).
|
||||
|
||||
This is a practical vertical slice: it enables the admin UI to create a league and see a schedule scaffold.
|
||||
|
||||
### 3.2 Repos/tokens INCLUDED in slice 1
|
||||
Implement Postgres/TypeORM repositories for the following tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51):
|
||||
|
||||
- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository`
|
||||
- [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository`
|
||||
- [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository`
|
||||
- [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository`
|
||||
- [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository`
|
||||
|
||||
Rationale:
|
||||
- Creation flow requires the first three via [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56).
|
||||
- Schedule flow requires `league`, `season`, `race` repos via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33).
|
||||
- Capacity listing and “join league” depend on membership repo via [`GetAllLeaguesWithCapacityUseCase`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:25) and [`JoinLeagueUseCase`](core/racing/application/use-cases/JoinLeagueUseCase.ts:18).
|
||||
|
||||
### 3.3 Repos/tokens DEFERRED (slice 2+)
|
||||
Explicitly defer these tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) to later phases:
|
||||
|
||||
- [`DRIVER_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51)
|
||||
- [`RESULT_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:54)
|
||||
- [`STANDING_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:55)
|
||||
- [`RACE_REGISTRATION_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:57)
|
||||
- [`TEAM_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:58)
|
||||
- [`TEAM_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:59)
|
||||
- Anything sponsorship/wallet-related in Racing persistence (tokens near [`LEAGUE_WALLET_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:66))
|
||||
|
||||
This keeps slice 1 focused and prevents exploding schema surface area.
|
||||
|
||||
---
|
||||
|
||||
## 4) Mapping strategy (ORM entities vs domain entities)
|
||||
|
||||
### 4.1 Boundary rule (non-negotiable)
|
||||
Core domain entities (example [`League`](core/racing/domain/entities/League.ts:93), [`Race`](core/racing/domain/entities/Race.ts:18)) MUST NOT import or refer to ORM entities, repositories, decorators, or TypeORM types, per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16) and [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24).
|
||||
|
||||
### 4.2 ORM entity design principles
|
||||
- ORM entities are persistence models optimized for storage/querying and can use primitives (string, number, Date) and JSON columns.
|
||||
- Domain entities use rich value objects and invariants and should be built using existing factories like [`League.create()`](core/racing/domain/entities/League.ts:132) and [`Race.create()`](core/racing/domain/entities/Race.ts:81).
|
||||
|
||||
### 4.3 Mapping responsibilities
|
||||
Mapping lives in `adapters/racing/persistence/typeorm/mappers/*` and is responsible for:
|
||||
|
||||
- `toDomain(orm)`:
|
||||
- Convert primitive columns/JSON back into domain props.
|
||||
- Call domain factory methods (`create`) with validated values.
|
||||
- Handle optional fields and backward-compatibility defaults (e.g., `League.settings` in [`League`](core/racing/domain/entities/League.ts:72)).
|
||||
- `toOrm(domain)`:
|
||||
- Convert domain value objects to primitives suitable for columns.
|
||||
- Define canonical serialization for nested structures (e.g., store `League.settings` as JSONB).
|
||||
|
||||
### 4.4 Proposed per-entity mapping notes (slice 1)
|
||||
|
||||
#### League
|
||||
- Persist fields:
|
||||
- `id` string (PK)
|
||||
- `name` string
|
||||
- `description` string
|
||||
- `ownerId` string
|
||||
- `createdAt` Date
|
||||
- `settings` JSONB (store `LeagueSettings` from [`LeagueSettings`](core/racing/domain/entities/League.ts:72))
|
||||
- `socialLinks` JSONB nullable
|
||||
- `participantCount` integer (if needed; domain tracks via internal `_participantCount` in [`League`](core/racing/domain/entities/League.ts:103))
|
||||
- `visibility` string (redundant to settings.visibility, but may be useful for querying; keep either:
|
||||
- Option A: derive from settings only and do not store separate column
|
||||
- Option B: store both and enforce consistency in mapper (preferred for query ergonomics)
|
||||
|
||||
#### Season
|
||||
- Keep Season as its own ORM entity with FK to leagueId (string).
|
||||
- Use JSONB for schedule (if schedule is a complex object), and scalar columns for status, year, order, start/end.
|
||||
|
||||
#### LeagueScoringConfig
|
||||
- Store `seasonId` string as unique FK.
|
||||
- Store scoring config payload (championships, points tables, bonus rules) as JSONB.
|
||||
|
||||
#### Race
|
||||
- Persist scalar fields corresponding to [`Race`](core/racing/domain/entities/Race.ts:18):
|
||||
- `id` string (PK)
|
||||
- `leagueId` string (indexed)
|
||||
- `scheduledAt` timestamptz
|
||||
- `track`, `trackId`, `car`, `carId` strings
|
||||
- `sessionType` string
|
||||
- `status` string (from [`RaceStatus`](core/racing/domain/entities/Race.ts:11))
|
||||
- `strengthOfField`, `registeredCount`, `maxParticipants` integers nullable
|
||||
- Queries required by Core ports (examples in [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10)):
|
||||
- find by leagueId
|
||||
- upcoming/completed filtering (status + scheduledAt)
|
||||
|
||||
#### LeagueMembership
|
||||
- Persist fields corresponding to [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts:25):
|
||||
- `id` string (domain uses default `${leagueId}:${driverId}` in [`LeagueMembership.create()`](core/racing/domain/entities/LeagueMembership.ts:49))
|
||||
- `leagueId` string (indexed)
|
||||
- `driverId` string (indexed)
|
||||
- `role` string
|
||||
- `status` string
|
||||
- `joinedAt` timestamptz
|
||||
- This enables membership queries required by [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13).
|
||||
|
||||
---
|
||||
|
||||
## 5) TypeORM + Nest wiring specifics
|
||||
|
||||
### 5.1 Database root config
|
||||
Current `DatabaseModule` uses [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) and does not register entities.
|
||||
|
||||
Plan change (minimal and controlled):
|
||||
- Update [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) to support loading Racing entities when Postgres persistence is enabled:
|
||||
- Add `autoLoadEntities: true` in the `forRoot` options so entities registered via feature modules are discovered.
|
||||
- Keep [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18) behavior as-is for non-prod for now (explicitly acknowledged technical debt).
|
||||
|
||||
Why:
|
||||
- We want Racing persistence to be modular (entities registered only when the Postgres Racing module is imported) without a global “list every entity in the world” change.
|
||||
|
||||
### 5.2 Postgres Racing module structure
|
||||
Replace placeholder providers in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51) with real wiring:
|
||||
|
||||
- `imports`:
|
||||
- [`LoggingModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:3) (already present)
|
||||
- Nest TypeORM feature registration for the slice 1 entities:
|
||||
- `TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, LeagueScoringConfigOrmEntity, RaceOrmEntity, LeagueMembershipOrmEntity])`
|
||||
- Mentioned as a method name; `TypeOrmModule` itself is already in use at [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
|
||||
- `providers`:
|
||||
- Register each repository implementation class under the existing tokens, matching the in-memory token names exactly (examples in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:75)):
|
||||
- Provide [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository`
|
||||
- Provide [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository`
|
||||
- Provide [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository`
|
||||
- Provide [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository`
|
||||
- Provide [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository`
|
||||
|
||||
- `exports`:
|
||||
- Export the same tokens so downstream modules remain unchanged, mirroring the export list pattern in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:182).
|
||||
|
||||
### 5.3 Repository implementation style
|
||||
Each `TypeOrm*Repository`:
|
||||
- Implements the relevant Core repository interface, e.g. [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10).
|
||||
- Depends only on:
|
||||
- TypeORM repository/DataSource types (adapter-layer OK)
|
||||
- Mappers in adapters
|
||||
- Domain entities/ports (core-layer OK)
|
||||
- Does not expose ORM entities outside adapters.
|
||||
|
||||
### 5.4 Persistence boundary selection remains the same
|
||||
Do not change selection semantics in [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:7). This module already selects Postgres vs in-memory using [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
|
||||
---
|
||||
|
||||
## 6) TDD-first phased rollout (tests first, controlled scope)
|
||||
|
||||
### 6.1 Testing goals
|
||||
- Add confidence that TypeORM repositories satisfy Core port contracts.
|
||||
- Keep default test runs fast and in-memory by default.
|
||||
- Add Postgres-backed integration tests that are opt-in (run only when explicitly enabled).
|
||||
|
||||
### 6.2 What tests already exist and should remain green
|
||||
- Persistence module selection test: [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) (currently asserts placeholder instance in Postgres mode). This will need updating once placeholders are replaced, but the intent remains valid.
|
||||
- Existing in-memory repository unit tests under `adapters/racing/persistence/inmemory/*.test.ts` (example: [`InMemoryLeagueRepository.test.ts`](adapters/racing/persistence/inmemory/InMemoryLeagueRepository.test.ts)) must remain untouched and continue to run by default.
|
||||
|
||||
### 6.3 New tests to write first (TDD sequence)
|
||||
|
||||
#### Phase A: Contract-style repo tests for TypeORM (integration tests, opt-in)
|
||||
Create a new test suite for each TypeORM repository in:
|
||||
- `adapters/racing/persistence/typeorm/repositories/*Repository.integration.test.ts`
|
||||
|
||||
Test approach:
|
||||
- Use a real Postgres database (not mocks) and TypeORM DataSource configured similarly to runtime config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
- Keep these tests skipped unless a flag is set, e.g. `RUN_RACING_PG_TESTS=1` (exact naming to be decided in implementation mode).
|
||||
- Use a dedicated DB name or schema per run and truncate tables between tests.
|
||||
|
||||
Example test cases (for slice 1):
|
||||
- `TypeOrmLeagueRepository` should satisfy basic operations defined in [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10):
|
||||
- create + findById roundtrip
|
||||
- findAll returns inserted
|
||||
- update roundtrip
|
||||
- exists works
|
||||
- `TypeOrmSeasonRepository` should satisfy [`ISeasonRepository`](core/racing/domain/repositories/ISeasonRepository.ts:3):
|
||||
- create + findById
|
||||
- findByLeagueId
|
||||
- `TypeOrmRaceRepository` should satisfy [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10):
|
||||
- create + findById
|
||||
- findByLeagueId
|
||||
- findUpcomingByLeagueId and findCompletedByLeagueId behavior (status + date)
|
||||
- `TypeOrmLeagueMembershipRepository` should satisfy [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13):
|
||||
- saveMembership + getMembership
|
||||
- getLeagueMembers filtering (active vs pending) must match whatever domain expects (start with minimal “returns all stored” behavior, then align with use cases)
|
||||
- `TypeOrmLeagueScoringConfigRepository` should satisfy [`ILeagueScoringConfigRepository`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3):
|
||||
- save + findBySeasonId
|
||||
|
||||
Why integration tests first:
|
||||
- It forces us to design the ORM schema + mapping in a way that matches the Core port contracts immediately.
|
||||
|
||||
#### Phase B: Update module selection test (unit test, always-on)
|
||||
Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert that in Postgres mode the provider resolves to the real TypeORM repo class (instead of the placeholder in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:30)).
|
||||
|
||||
This remains a fast unit test: it only checks Nest DI wiring, not DB behavior.
|
||||
|
||||
### 6.4 What to mock vs run “for real”
|
||||
- Mock nothing for repository integration tests (they should hit Postgres).
|
||||
- Keep Core use case tests (if any exist) running with in-memory repos or test doubles by default.
|
||||
- Do not switch existing HTTP tests to Postgres by default (many explicitly set in-memory via env in files like those discovered under `apps/api/src/domain/league/*.http.test.ts` via earlier repo search).
|
||||
|
||||
### 6.5 Keeping default tests in in-memory mode
|
||||
- Preserve the current default behavior where tests set [`GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7) to `inmemory` (example in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)).
|
||||
- Ensure the Postgres integration tests are opt-in and not included in default `npm run api:test` from [`apps/api/package.json`](apps/api/package.json:10).
|
||||
|
||||
---
|
||||
|
||||
## 7) Dev bootstrap/seed strategy for Postgres (minimal, idempotent, non-test)
|
||||
|
||||
### 7.1 Current behavior
|
||||
Bootstrap currently seeds racing data only in in-memory mode via [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37), which returns `true` only for `inmemory`.
|
||||
|
||||
### 7.2 Target behavior
|
||||
In dev Postgres mode, seed minimal Racing data only when the database is empty, and never during tests.
|
||||
|
||||
Proposed logic change in [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10):
|
||||
- Seed when ALL are true:
|
||||
- `NODE_ENV !== 'production'`
|
||||
- persistence is Postgres via [`getApiPersistence()`](apps/api/src/env.ts:33)
|
||||
- database appears empty for Racing (fast check: `driverRepository.findAll().length === 0` as already used in [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55))
|
||||
- bootstrap is enabled (already toggled globally in app startup via [`getEnableBootstrap()`](apps/api/src/env.ts:49) and [`AppModule`](apps/api/src/app.module.ts:29))
|
||||
|
||||
Implementation detail:
|
||||
- Keep using [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) because it is already idempotent-ish (it skips when drivers exist at [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)).
|
||||
- Ensure Postgres-backed `driverRepository` is not required for slice 1 if we keep seed minimal; however, current seed checks drivers first, so this implies either:
|
||||
- Option A (preferred for UI usefulness): include Driver repo in Postgres slice 1b (small extension) so seeding can run fully, or
|
||||
- Option B (controlled scope): create a Postgres-only “minimal seed” class that checks `leagueRepository.findAll()` instead of drivers and seeds only leagues/seasons/races/memberships/scoring configs.
|
||||
|
||||
To keep scope controlled and aligned with “Racing only”, choose Option B for slice 1:
|
||||
- Introduce `SeedRacingDataMinimal` under `adapters/bootstrap/racing/` that seeds:
|
||||
- one league
|
||||
- one active season
|
||||
- 0..N races in the season window
|
||||
- one membership for the owner (so capacity endpoints have meaningful data)
|
||||
- one scoring config for the active season
|
||||
- Keep it idempotent:
|
||||
- skip if `leagueRepository.findAll()` returns any leagues
|
||||
- upsert behavior for scoring config by seasonId (align with [`ILeagueScoringConfigRepository.findBySeasonId()`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3))
|
||||
|
||||
Test contamination avoidance:
|
||||
- Tests already default to in-memory persistence and can also set `GRIDPILOT_API_BOOTSTRAP` to false if needed via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
|
||||
---
|
||||
|
||||
## 8) Dev ergonomics: docker-compose dev toggle change (exact change)
|
||||
|
||||
### 8.1 Current issue
|
||||
`docker-compose.dev.yml` forces in-memory persistence via [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36), which overrides the inference behavior in [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
|
||||
### 8.2 Proposed exact change (minimal)
|
||||
Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) to stop hard-forcing in-memory:
|
||||
|
||||
- Replace the hard-coded line at [`docker-compose.dev.yml`](docker-compose.dev.yml:36) with:
|
||||
- `- GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-postgres}`
|
||||
|
||||
Expected dev behavior:
|
||||
- Default dev stack uses Postgres persistence (because compose default becomes `postgres`).
|
||||
- Developers can still run in-memory explicitly by setting `GRIDPILOT_API_PERSISTENCE=inmemory` before running the compose command.
|
||||
|
||||
Alternative (if you prefer to preserve auto-detection):
|
||||
- Remove the line entirely and rely on [`getApiPersistence()`](apps/api/src/env.ts:33) + `DATABASE_URL` presence in `.env.development`.
|
||||
|
||||
This plan recommends the explicit compose default approach because it is more deterministic and avoids hidden coupling to `.env.development` contents.
|
||||
|
||||
---
|
||||
|
||||
## 9) Phased implementation plan (step-by-step)
|
||||
|
||||
### Phase 1: Prepare TypeORM adapter skeleton (no behavior change)
|
||||
1) Add the folder structure described in section 2.
|
||||
2) Add the ORM entity files for the slice 1 domain models with minimal columns and constraints (PKs, required columns, basic indices).
|
||||
3) Add mapper stubs with round-trip intent documented.
|
||||
4) Add repository class stubs that implement the Core interfaces but throw “not implemented” only for methods not used in slice 1 tests.
|
||||
|
||||
Gate:
|
||||
- No changes to runtime wiring yet; existing tests remain green.
|
||||
|
||||
### Phase 2: Add opt-in Postgres integration tests (TDD)
|
||||
1) Add TypeORM DataSource test helper.
|
||||
2) Write failing integration tests for `TypeOrmLeagueRepository` and implement it until green.
|
||||
3) Repeat for `Season`, `Race`, `Membership`, `ScoringConfig` repos.
|
||||
|
||||
Gate:
|
||||
- Integration tests pass when enabled.
|
||||
- Default `npm run api:test` remains unaffected.
|
||||
|
||||
### Phase 3: Wire Postgres Racing module to real repos (DI correctness)
|
||||
1) Update [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51):
|
||||
- Import TypeORM feature registration (section 5.2).
|
||||
- Replace placeholder providers with real repository providers for slice 1 tokens.
|
||||
2) Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert the Postgres providers resolve to the real TypeORM repo classes (instead of placeholders).
|
||||
|
||||
Gate:
|
||||
- Always-on module selection tests pass.
|
||||
|
||||
### Phase 4: Enable dev Postgres UX (bootstrap + compose)
|
||||
1) Update [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) to seed minimal Racing data in dev Postgres mode only when empty.
|
||||
2) Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) per section 8.
|
||||
|
||||
Gate:
|
||||
- `docker compose` dev stack can run with Postgres persistence and UI has minimal data.
|
||||
|
||||
### Phase 5: Incrementally expand beyond slice 1 (future, explicitly not required to finish now)
|
||||
Add Driver + Result + Standings etc only when a concrete UI/endpoint requires them and after writing the next integration tests first.
|
||||
|
||||
---
|
||||
|
||||
## 10) Verification gates (exact commands and when)
|
||||
|
||||
Run these at the end of each phase that changes TS code:
|
||||
|
||||
- ESLint:
|
||||
- `npm run lint` (script defined at [`package.json`](package.json:80))
|
||||
- TypeScript:
|
||||
- `npm run typecheck:targets` (script defined at [`package.json`](package.json:120))
|
||||
- API tests:
|
||||
- `npm run api:test` (script defined at [`package.json`](package.json:64)) or `npm run test --workspace=@gridpilot/api` via [`apps/api/package.json`](apps/api/package.json:10)
|
||||
|
||||
For opt-in Postgres repository integration tests (added in Phase 2):
|
||||
- Define a dedicated command (implementation-mode decision), but the plan expects it to be an explicit command that developers run intentionally (not part of default CI).
|
||||
|
||||
---
|
||||
|
||||
## 11) Risks and mitigations
|
||||
|
||||
- Risk: ORM entities leak into Core through shared types.
|
||||
- Mitigation: enforce mappers in adapters only, keep interfaces as Core ports (example [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10)).
|
||||
- Risk: Seed logic contaminates tests.
|
||||
- Mitigation: preserve default in-memory persistence in tests (example env usage in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)) and gate seeding by non-prod + emptiness checks; tests can disable bootstrap via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
- Risk: TypeORM entity registration not picked up because entities not configured.
|
||||
- Mitigation: enable `autoLoadEntities` in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) as part of implementation.
|
||||
|
||||
---
|
||||
|
||||
## 12) Ready-for-approval questions (for implementation mode to resolve quickly)
|
||||
|
||||
These are the only decisions that materially affect implementation detail:
|
||||
|
||||
1) Prefer Postgres integration tests using a developer-managed Postgres (via `DATABASE_URL`) or a dedicated docker-compose test database?
|
||||
2) For slice 1 seed, should we implement a minimal Racing-only seed (recommended) or extend slice 1 to include Driver repo so we can reuse [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) unchanged?
|
||||
@@ -1,300 +0,0 @@
|
||||
# Racing TypeORM Adapter Clean Architecture Audit + Strict Refactor Guide
|
||||
|
||||
Scope focus: all persistence adapter code under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm:1), especially mappers (incl. JSON mappers) and repositories, plus the ORM entities and adapter-scoped errors they depend on.
|
||||
|
||||
This guide is intentionally strict and implementation-ready.
|
||||
|
||||
---
|
||||
|
||||
## Governing constraints (authoritative)
|
||||
|
||||
- **Strict inward dependencies**: [`Only dependency-inward is allowed.`](docs/architecture/DATA_FLOW.md:24)
|
||||
- **Domain purity / no IO in domain objects**: [`Entities MUST NOT perform IO.`](docs/architecture/DOMAIN_OBJECTS.md:49)
|
||||
- **Persisted objects must rehydrate, not create**: [`Existing entities are reconstructed via rehydrate().`](docs/architecture/DOMAIN_OBJECTS.md:57)
|
||||
- **Adapters translate only (no orchestration/business logic)**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
- **Persistence entity placement**: ORM entities live in adapters, not domain: [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47)
|
||||
|
||||
---
|
||||
|
||||
## Adapter surface inventory (audited)
|
||||
|
||||
### Mappers
|
||||
- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30)
|
||||
- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5)
|
||||
- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4)
|
||||
- [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42)
|
||||
- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17)
|
||||
- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7)
|
||||
|
||||
### Repositories
|
||||
- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9)
|
||||
- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9)
|
||||
- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9)
|
||||
- [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9)
|
||||
|
||||
### ORM entities
|
||||
- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4)
|
||||
- [`RaceOrmEntity`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:4)
|
||||
- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4)
|
||||
- [`LeagueScoringConfigOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts:6)
|
||||
|
||||
### Adapter-scoped errors
|
||||
- [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)
|
||||
|
||||
---
|
||||
|
||||
## Concrete violations (file + function, why, severity)
|
||||
|
||||
Severity rubric:
|
||||
- **Blocker**: violates non-negotiable constraints; can cause domain invariants to run on persisted state, or leaks construction/orchestration into adapters.
|
||||
- **Follow-up**: does not strictly violate constraints, but is unsafe/unclear and should be corrected to align with the canonical strict pattern defined below.
|
||||
|
||||
### 1) Rehydration violations (calling `create()` when loading from DB) — **Blocker**
|
||||
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46)
|
||||
- **Why**: Uses [`League.create()`](core/racing/domain/entities/League.ts:132) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57) semantics (new vs existing).
|
||||
- **Impact**: running creation-time defaulting + validation on persisted state can mutate meaning (e.g., defaults merged in [`League.create()`](core/racing/domain/entities/League.ts:132)) and can throw domain validation errors due to persistence schema drift.
|
||||
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- **Why**: Uses [`Race.create()`](core/racing/domain/entities/Race.ts:81) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57).
|
||||
- **Impact**: DB rows become subject to “new entity” validations; adapter loses ability to separate “invalid persisted schema” (adapter concern) from “invalid new command” (domain concern).
|
||||
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- **Why**: Uses [`Season.create()`](core/racing/domain/entities/season/Season.ts:70) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57).
|
||||
- **Impact**: same as above, plus schedule/scoring/drop/stewarding props are passed as `any`, making persisted-state validation unpredictable.
|
||||
|
||||
- Positive control (already compliant): [`LeagueScoringConfigOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:54)
|
||||
- Uses [`LeagueScoringConfig.rehydrate()`](core/racing/domain/entities/LeagueScoringConfig.ts:63) and validates schema before converting.
|
||||
- This is the baseline pattern to replicate.
|
||||
|
||||
### 2) Adapter “translation only” violations (construction / orchestration inside repositories) — **Blocker**
|
||||
|
||||
- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10)
|
||||
- **Why**: Default-constructs a mapper via `new` ([`new LeagueOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:12)).
|
||||
- **Rule violated**: adapters must “translate only” ([`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)); object graph construction belongs in the composition root (Nest module).
|
||||
- **Impact**: makes DI inconsistent, harder to test, and encourages mapper graphs to be built ad-hoc in infrastructure code rather than composed centrally.
|
||||
|
||||
- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10)
|
||||
- **Why**: Default-constructs mapper via [`new RaceOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:12).
|
||||
- **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
- **Impact**: same.
|
||||
|
||||
- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10)
|
||||
- **Why**: Default-constructs mapper via [`new SeasonOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:12).
|
||||
- **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
|
||||
- Positive control (already aligned): [`TypeOrmLeagueScoringConfigRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:10)
|
||||
- Requires mapper injection (no internal construction), and is enforced by [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17).
|
||||
|
||||
### 3) Persistence schema typing issues that currently force `unknown`/`any` translation — **Follow-up (but required by the canonical strict pattern)**
|
||||
|
||||
These aren’t explicitly spelled out in the architecture docs, but they directly undermine “adapters translate only” by making translation ambiguous and unsafe. They also block strict `rehydrate()` mapping because you can’t validate/interpret persisted JSON precisely.
|
||||
|
||||
- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17)
|
||||
- Current: `Record<string, unknown>`
|
||||
- Problem: forces coercion and casts in [`LeagueOrmMapper.toOrmEntity()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:31) and [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46).
|
||||
|
||||
- [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32), [`SeasonOrmEntity.scoringConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:38), [`SeasonOrmEntity.dropPolicy`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:41), [`SeasonOrmEntity.stewardingConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:44)
|
||||
- Current: `Record<string, unknown> | null`
|
||||
- Problem: propagates into pervasive `as any` in [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26).
|
||||
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) and [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- Current: `as any` casts for status/sessionType/schedule/etc.
|
||||
- Problem: translation is not explicit/verified; makes persisted schema errors show up as domain behavior or runtime surprises.
|
||||
|
||||
### 4) Persistence boundary error mapping gaps — **Blocker**
|
||||
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46), [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23), [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- **Why**: because these call `create()` and use unsafe casts, the adapter does not reliably distinguish:
|
||||
- invalid persisted schema (adapter concern) vs
|
||||
- invalid incoming command/request (domain concern).
|
||||
- **Rule violated (intent)**: separation of roles per [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) and persisted rehydration rule ([`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57)).
|
||||
- **Impact**: domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) can leak due to persistence drift, making debugging and recovery much harder.
|
||||
|
||||
---
|
||||
|
||||
## Canonical strict pattern for this repo (target state)
|
||||
|
||||
This is the “golden path” all Racing TypeORM adapters should follow.
|
||||
|
||||
### A) ORM entity shape rules (including JSON columns)
|
||||
|
||||
- ORM entities are allowed in adapters per [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47).
|
||||
- JSON columns must be **typed to a concrete serialized shape** (no `unknown`, no `Record<string, unknown>`).
|
||||
- Example target types:
|
||||
- `LeagueOrmEntity.settings: SerializedLeagueSettings`
|
||||
- `SeasonOrmEntity.schedule: SerializedSeasonSchedule | null`
|
||||
- `SeasonOrmEntity.scoringConfig: SerializedSeasonScoringConfig | null`
|
||||
- Serialized types must be:
|
||||
- JSON-safe (no `Date`, `Map`, class instances)
|
||||
- versionable (allow future `schemaVersion?: number`)
|
||||
- strict enough to validate at runtime
|
||||
- Use `null` for absent optional columns (as already done in [`RaceOrmEntity.trackId`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:17)).
|
||||
|
||||
### B) Mapper rules (pure translation, no side effects)
|
||||
|
||||
- Mappers are pure, deterministic translators:
|
||||
- `domain -> orm` and `orm -> domain`
|
||||
- no IO, no logging, no Date.now(), no random ids
|
||||
- **No `as any`** in mapping. If types don’t line up, fix the schema types or add a safe interpreter.
|
||||
- **No `create()` on load**. `orm -> domain` must call `rehydrate()` semantics for entities ([`rehydrate()` rule](docs/architecture/DOMAIN_OBJECTS.md:57)).
|
||||
- On `orm -> domain`:
|
||||
- validate persisted schema and throw an **adapter-scoped persistence schema error**, not a domain validation error.
|
||||
- treat invalid persisted schema as infrastructure failure (data corruption/migration mismatch).
|
||||
- On `domain -> orm`:
|
||||
- serialize domain objects via explicit “serialize” helpers (no `as unknown as Record<...>`).
|
||||
- JSON mappers:
|
||||
- must be pure
|
||||
- must return serialized DTO-like shapes, not domain objects except as output of `fromJson`
|
||||
- should avoid calling value-object constructors directly unless the VO explicitly treats constructor as a safe “rehydrate”; otherwise introduce `fromJson()`/`rehydrate()` for VOs.
|
||||
|
||||
### C) Repository rules (IO + mapper only; composition root in Nest module)
|
||||
|
||||
- Repositories implement core ports and do:
|
||||
- DB IO (TypeORM queries)
|
||||
- mapping via injected mappers
|
||||
- Repositories must not:
|
||||
- construct mappers internally (`new` in constructor defaults)
|
||||
- embed business logic/orchestration (that belongs to application services / use cases per [`Use Cases decide... Adapters translate.`](docs/architecture/DATA_FLOW.md:437))
|
||||
- Construction belongs in the Nest composition root:
|
||||
- e.g. [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) should provide mapper instances and inject them into repositories.
|
||||
|
||||
### D) Error handling rules (adapter-scoped errors vs domain errors)
|
||||
|
||||
- Domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) are for rejecting invalid **commands/new state transitions**.
|
||||
- Persistence adapters must throw adapter-scoped errors for:
|
||||
- invalid persisted JSON schema
|
||||
- impossible enum values/statuses stored in DB
|
||||
- missing required persisted columns
|
||||
- Pattern baseline:
|
||||
- follow [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) (adapter-owned, descriptive name, extends `Error`).
|
||||
- Repositories generally should not catch/translate DB errors unless you have a stable policy (e.g. unique violations) — keep this explicit and adapter-scoped if introduced.
|
||||
|
||||
---
|
||||
|
||||
## Controlled refactor plan (2–3 slices)
|
||||
|
||||
Each slice is designed to be reviewable and to keep the system runnable.
|
||||
|
||||
### Slice 1 — Mappers: `rehydrate()` + typed JSON schemas (DB-free unit tests)
|
||||
|
||||
**Goal**
|
||||
- Make all `orm -> domain` mapping use rehydration semantics and strict, typed persisted schemas.
|
||||
- Remove `as any` from mapper paths by fixing schema types and adding validators.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Mapper implementations:
|
||||
- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30)
|
||||
- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5)
|
||||
- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4)
|
||||
- (keep as reference) [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42)
|
||||
- JSON mappers as needed:
|
||||
- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7)
|
||||
- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17)
|
||||
- ORM entities (type JSON columns to serialized types):
|
||||
- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4)
|
||||
- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4)
|
||||
- Add adapter-scoped schema error types (new files under the existing folder):
|
||||
- create new errors in [`adapters/racing/persistence/typeorm/errors/`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)
|
||||
- Core changes required to satisfy rehydration rule (yes, this reaches into core because the adapter cannot comply otherwise):
|
||||
- add `static rehydrate(...)` to:
|
||||
- [`League`](core/racing/domain/entities/League.ts:93)
|
||||
- [`Race`](core/racing/domain/entities/Race.ts:18)
|
||||
- [`Season`](core/racing/domain/entities/season/Season.ts:14)
|
||||
|
||||
**Acceptance tests (DB-free)**
|
||||
- Add mapper unit tests in the same pattern as [`LeagueScoringConfigOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts:10):
|
||||
- new: `LeagueOrmMapper.test.ts` verifies [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) calls `rehydrate()` and does not call [`League.create()`](core/racing/domain/entities/League.ts:132).
|
||||
- new: `RaceOrmMapper.test.ts` verifies [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) uses `rehydrate()` and never calls [`Race.create()`](core/racing/domain/entities/Race.ts:81).
|
||||
- new: `SeasonOrmMapper.test.ts` verifies [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) uses `rehydrate()` and never calls [`Season.create()`](core/racing/domain/entities/season/Season.ts:70).
|
||||
- Add schema validation tests:
|
||||
- invalid JSON column shapes throw adapter error types (similar to [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)), not domain validation errors.
|
||||
|
||||
**Definition of done**
|
||||
- No `create()` calls in any `toDomain()` for persisted entities.
|
||||
- No `as any` in mapper implementations.
|
||||
- JSON columns are typed to explicit `Serialized*` types, and validated on load.
|
||||
|
||||
---
|
||||
|
||||
### Slice 2 — Repository wiring cleanup (DI, no `new` inside repos)
|
||||
|
||||
**Goal**
|
||||
- Repositories remain IO + mapper only.
|
||||
- Mapper graphs are constructed in the Nest module composition root.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Repositories:
|
||||
- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9)
|
||||
- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9)
|
||||
- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9)
|
||||
- (already OK, keep consistent) [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9)
|
||||
- Composition root wiring:
|
||||
- [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57)
|
||||
- Integration tests that new repos directly (update constructor signatures):
|
||||
- [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55)
|
||||
- Mapper tests that assume default constructors (update as needed):
|
||||
- [`RacingOrmMappers.test.ts`](apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts:20)
|
||||
|
||||
**Acceptance tests**
|
||||
- Add repository constructor tests mirroring the existing pattern in [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17):
|
||||
- new: `TypeOrmLeagueRepository.test.ts` asserts no internal `new LeagueOrmMapper()`.
|
||||
- new: `TypeOrmRaceRepository.test.ts` asserts no internal `new RaceOrmMapper()`.
|
||||
- new: `TypeOrmSeasonRepository.test.ts` asserts no internal `new SeasonOrmMapper()`.
|
||||
- `tsc` should enforce injection by making mapper a required constructor param (strongest guard).
|
||||
|
||||
**Definition of done**
|
||||
- No repository has a default `new Mapper()` in constructor params.
|
||||
- Nest module provides mapper instances and injects them into repositories.
|
||||
|
||||
---
|
||||
|
||||
### Slice 3 (optional) — Postgres integration tests + minimal vertical verification
|
||||
|
||||
**Goal**
|
||||
- Verify the new strict schemas + rehydrate semantics survive real persistence roundtrips.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Existing integration test:
|
||||
- [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:31)
|
||||
- Potentially add one focused mapper+repo roundtrip test per aggregate if gaps remain:
|
||||
- extend [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) rather than adding many files.
|
||||
|
||||
**Acceptance tests**
|
||||
- With `DATABASE_URL` set, integration suite passes and persists/reads:
|
||||
- League with settings JSON
|
||||
- Season with nullable JSON configs
|
||||
- Race with status/sessionType mapping
|
||||
- LeagueScoringConfig with championships JSON (already covered)
|
||||
|
||||
---
|
||||
|
||||
## Notes on current wiring (for context)
|
||||
|
||||
- Nest composition root is already the correct place for construction:
|
||||
- [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57)
|
||||
- But it currently relies on repository default constructors for some mappers:
|
||||
- e.g. provides [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) via `new TypeOrmLeagueRepository(dataSource)` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:66)
|
||||
- League scoring config is already composed explicitly (good reference):
|
||||
- constructs `PointsTableJsonMapper -> ChampionshipConfigJsonMapper -> LeagueScoringConfigOrmMapper` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:126)
|
||||
|
||||
---
|
||||
|
||||
## Top blockers (short list)
|
||||
|
||||
- Persisted entity mapping calls `create()` instead of `rehydrate()`:
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46)
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- Repositories build mappers internally (construction not confined to composition root):
|
||||
- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10)
|
||||
- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10)
|
||||
- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10)
|
||||
- Untyped JSON columns and `as any` casts prevent strict translation and reliable schema error handling:
|
||||
- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17)
|
||||
- [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32)
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
@@ -1,441 +0,0 @@
|
||||
# Website auth + route protection rethink (class-based single concept)
|
||||
|
||||
Goal: replace the current mixed system of Next middleware + client guards + demo cookies + alpha mode branches with **one coherent, predictable system** implemented via a small set of **clean, solid classes**.
|
||||
|
||||
Non-negotiables:
|
||||
|
||||
- **Server-side is canonical** for access control and redirects.
|
||||
- **Client-side is UX only** (show/hide UI, session-aware components) and never a source of truth.
|
||||
- “Demo” is just **a predefined user account**; no special routing/auth logic.
|
||||
- “Alpha mode” is removed; **feature flags** decide what UI/features are visible.
|
||||
|
||||
This plan is designed to keep existing integration coverage in [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) passing, adjusting tests only when the old behavior was accidental.
|
||||
|
||||
---
|
||||
|
||||
## 1) Current state (what exists today)
|
||||
|
||||
### 1.1 Server-side (Edge middleware)
|
||||
|
||||
[`apps/website/middleware.ts`](../apps/website/middleware.ts:1) currently:
|
||||
|
||||
- Treats presence of cookie `gp_session` as “authenticated”.
|
||||
- Uses a hardcoded `publicRoutes` array derived from [`routes`](../apps/website/lib/routing/RouteConfig.ts:114).
|
||||
- Redirects unauthenticated users to `/auth/login?returnTo=...`.
|
||||
- Redirects authenticated users away from `/auth/*` pages based on cookie `gridpilot_demo_mode` (special-case sponsor).
|
||||
|
||||
Problems:
|
||||
|
||||
- Cookie presence ≠ valid session (session drift tests exist).
|
||||
- Authorization decisions are made without server-side session validation.
|
||||
- Demo cookies influence routing decisions (non-canonical).
|
||||
|
||||
### 1.2 Client-side (guards + AuthContext)
|
||||
|
||||
- [`apps/website/lib/auth/AuthContext.tsx`](../apps/website/lib/auth/AuthContext.tsx:1) fetches session via `sessionService.getSession()` on mount.
|
||||
- Client-only route wrappers:
|
||||
- [`apps/website/lib/guards/AuthGuard.tsx`](../apps/website/lib/guards/AuthGuard.tsx:1)
|
||||
- [`apps/website/lib/guards/RoleGuard.tsx`](../apps/website/lib/guards/RoleGuard.tsx:1)
|
||||
|
||||
Problems:
|
||||
|
||||
- Double guarding: middleware may redirect, and guards may redirect again after hydration (flicker).
|
||||
- Guards treat “wrong role” like “unauthenticated” (this is fine and matches chosen UX), but enforcement is inconsistent.
|
||||
|
||||
### 1.3 “Alpha mode” and demo exceptions
|
||||
|
||||
- [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) branches on `mode === 'alpha'` and renders a different shell.
|
||||
- Demo logic leaks into routing via `gridpilot_demo_mode` in middleware (and various components).
|
||||
- Tests currently set cookies like `gridpilot_demo_mode`, sponsor id/name, plus drift cookies (see [`tests/integration/website/websiteAuth.ts`](../tests/integration/website/websiteAuth.ts:1)).
|
||||
|
||||
We will remove all of this:
|
||||
|
||||
- **No alpha mode**: replaced with feature flags.
|
||||
- **No demo routing exceptions**: demo is a user, not a mode.
|
||||
|
||||
---
|
||||
|
||||
## 2) Target concept (one clean concept expressed as classes)
|
||||
|
||||
### 2.1 Definitions
|
||||
|
||||
**Authentication**
|
||||
|
||||
- A request is “authenticated” iff API `/auth/session` (or `/api/auth/session`) returns a valid session object.
|
||||
- The `gp_session` cookie is an opaque session identifier; presence alone is never trusted.
|
||||
|
||||
**Authorization**
|
||||
|
||||
- A request is “authorized” for a route iff the session exists and session role satisfies the route requirement.
|
||||
|
||||
**Canonical redirect behavior (approved)**
|
||||
|
||||
- If route is protected and user is unauthenticated OR unauthorized (wrong role):
|
||||
- redirect to `/auth/login?returnTo=<current path>`.
|
||||
|
||||
This is intentionally strict and matches the existing integration expectations for role checks.
|
||||
|
||||
### 2.2 Where things live (server vs client)
|
||||
|
||||
**Server-side (canonical)**
|
||||
|
||||
- Route protection + redirects, implemented in Next App Router **server layouts**.
|
||||
- Route access matrix is defined once and reused.
|
||||
|
||||
**Client-side (UX only)**
|
||||
|
||||
- `AuthProvider` holds `session` to render navigation, user pill, etc.
|
||||
- Client may refresh session on demand (after login/logout), but not on every navigation.
|
||||
|
||||
---
|
||||
|
||||
## 3) Proposed architecture (clean classes)
|
||||
|
||||
The core idea: build a tiny “auth kernel” for the website that provides:
|
||||
|
||||
- route access decisions (pure)
|
||||
- server session retrieval (gateway)
|
||||
- redirect URL construction (pure + safe)
|
||||
- route enforcement (guards)
|
||||
|
||||
These are classes so responsibilities are explicit, testable, and deletions are easy.
|
||||
|
||||
### 3.1 Class inventory (what we will build)
|
||||
|
||||
This section also addresses the hard requirement:
|
||||
|
||||
- avoid hardcoded route pathnames so we can extend later (e.g. i18n)
|
||||
|
||||
That means:
|
||||
|
||||
- internal logic talks in **route IDs / route patterns**, not raw string paths
|
||||
- redirects are built via **route builders** (locale-aware)
|
||||
- policy checks run on a **normalized logical pathname** (locale stripped)
|
||||
|
||||
#### 3.1.1 `RouteAccessPolicy`
|
||||
|
||||
**Responsibility:** answer “what does this path require?”
|
||||
|
||||
Inputs:
|
||||
|
||||
- `logicalPathname` (normalized path, locale removed; see `PathnameInterpreter`)
|
||||
|
||||
Outputs:
|
||||
|
||||
- `isPublic(pathname): boolean`
|
||||
- `isAuthPage(pathname): boolean` (e.g. `/auth/*`)
|
||||
- `requiredRoles(pathname): string[] | null`
|
||||
- `roleHome(role): string`
|
||||
|
||||
Source of truth for route set:
|
||||
|
||||
- The existing inventory in [`tests/integration/website/websiteRouteInventory.ts`](../tests/integration/website/websiteRouteInventory.ts:1) must remain consistent with runtime rules.
|
||||
- Canonical route constants remain in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114).
|
||||
|
||||
Why a class?
|
||||
|
||||
- Centralizes route matrix and prevents divergence between middleware/guards/layouts.
|
||||
|
||||
Avoiding hardcoded paths:
|
||||
|
||||
- `RouteAccessPolicy` should not hardcode strings like `/auth/login`.
|
||||
- It should instead rely on a `RouteCatalog` (below) that exposes route IDs + patterns derived from [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114).
|
||||
|
||||
#### 3.1.2 `ReturnToSanitizer`
|
||||
|
||||
**Responsibility:** make `returnTo` safe and predictable.
|
||||
|
||||
- `sanitizeReturnTo(input: string | null, fallbackPathname: string): string`
|
||||
|
||||
Rules:
|
||||
|
||||
- Only allow relative paths starting with `/`.
|
||||
- Strip protocol/host if someone passes an absolute URL.
|
||||
- Optionally disallow `/api/*` and static assets.
|
||||
|
||||
Why a class?
|
||||
|
||||
- Open redirects become impossible by construction.
|
||||
|
||||
#### 3.1.3 `SessionGateway` (server-only)
|
||||
|
||||
**Responsibility:** fetch the canonical session for the current request.
|
||||
|
||||
- `getSession(): Promise<AuthSessionDTO | null>`
|
||||
|
||||
Implementation details:
|
||||
|
||||
- Use server-side `cookies()` to read the incoming cookies.
|
||||
- Call same-origin `/api/auth/session` so Next rewrites (see [`apps/website/next.config.mjs`](../apps/website/next.config.mjs:52)) forward to the API.
|
||||
- Forward cookies via the `cookie` header.
|
||||
- Treat any non-OK response as `null` (never throw for auth checks).
|
||||
|
||||
Why a class?
|
||||
|
||||
- Encapsulates the “server fetch with forwarded cookies” complexity.
|
||||
|
||||
#### 3.1.4 `AuthRedirectBuilder`
|
||||
|
||||
**Responsibility:** construct redirect targets consistently (and locale-aware).
|
||||
|
||||
- `toLogin({ current }): string` → `<login route>?returnTo=<sanitized current>`
|
||||
- `awayFromAuthPage({ session }): string` → role home (driver/sponsor/admin)
|
||||
|
||||
Internally uses:
|
||||
|
||||
- `RouteAccessPolicy` for roleHome decision
|
||||
- `ReturnToSanitizer` for returnTo
|
||||
- `RoutePathBuilder` (below) so we do not hardcode `/auth/login` or `/dashboard`
|
||||
|
||||
Why a class?
|
||||
|
||||
- Eliminates copy/paste `URLSearchParams` and subtle mismatches.
|
||||
|
||||
#### 3.1.5 `RouteGuard` (server-only)
|
||||
|
||||
**Responsibility:** enforce the policy by redirecting.
|
||||
|
||||
- `enforce({ pathname }): Promise<void>`
|
||||
|
||||
Logic:
|
||||
|
||||
1. If `isPublic(pathname)` and not an auth page: allow.
|
||||
2. If `isAuthPage(pathname)`:
|
||||
- if session exists: redirect to role home
|
||||
- else: allow
|
||||
3. If protected:
|
||||
- if no session: redirect to login
|
||||
- if `requiredRoles(pathname)` and role not included: redirect to login (approved UX)
|
||||
- else: allow
|
||||
|
||||
Why a class?
|
||||
|
||||
- Moves all enforcement into one place.
|
||||
|
||||
#### 3.1.6 `FeatureFlagService` (server + client)
|
||||
|
||||
**Responsibility:** replace “alpha mode” with flags.
|
||||
|
||||
- `isEnabled(flag): boolean`
|
||||
|
||||
Rules:
|
||||
|
||||
- Flags can hide UI or disable pages, but **must not** bypass auth.
|
||||
|
||||
Note: implementation depends on your existing flag system; the plan assumes it exists and becomes the only mechanism.
|
||||
|
||||
### 3.1.7 `PathnameInterpreter` (i18n-ready, server-only)
|
||||
|
||||
**Responsibility:** turn an incoming Next.js `pathname` into a stable “logical” pathname plus locale.
|
||||
|
||||
- `interpret(pathname: string): { locale: string | null; logicalPathname: string }`
|
||||
|
||||
Rules:
|
||||
|
||||
- If later you add i18n where URLs look like `/<locale>/...`, this class strips the locale prefix.
|
||||
- If you add Next `basePath`, this class can also strip it.
|
||||
|
||||
This allows the rest of the auth system to remain stable even if the URL structure changes.
|
||||
|
||||
### 3.1.8 `RouteCatalog` + `RoutePathBuilder` (no hardcoded strings)
|
||||
|
||||
**Responsibility:** remove stringly-typed routes from the auth system.
|
||||
|
||||
`RouteCatalog` exposes:
|
||||
|
||||
- route IDs (e.g. `auth.login`, `protected.dashboard`, `sponsor.dashboard`, `admin.root`)
|
||||
- route patterns (for matching): sourced from [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114)
|
||||
- helpers built on existing matching tools like `routeMatchers` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193)
|
||||
|
||||
`RoutePathBuilder` builds locale-aware URLs:
|
||||
|
||||
- `build(routeId, params?, { locale? }): string`
|
||||
|
||||
Implementation direction:
|
||||
|
||||
- Use the existing `routes` object + `buildPath()` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:307) as the underlying canonical mapping.
|
||||
- Add an optional locale prefix when i18n is introduced.
|
||||
|
||||
With this, auth code never writes literals like `/auth/login`, `/dashboard`, `/sponsor/dashboard`.
|
||||
|
||||
### 3.2 How the classes are used (App Router)
|
||||
|
||||
Route enforcement happens in **server layouts**:
|
||||
|
||||
- [`apps/website/app/dashboard/layout.tsx`](../apps/website/app/dashboard/layout.tsx:1)
|
||||
- [`apps/website/app/admin/layout.tsx`](../apps/website/app/admin/layout.tsx:1)
|
||||
- [`apps/website/app/sponsor/layout.tsx`](../apps/website/app/sponsor/layout.tsx:1)
|
||||
- [`apps/website/app/profile/layout.tsx`](../apps/website/app/profile/layout.tsx:1)
|
||||
- [`apps/website/app/onboarding/layout.tsx`](../apps/website/app/onboarding/layout.tsx:1)
|
||||
|
||||
Each layout becomes a small server component wrapper:
|
||||
|
||||
1. Instantiate `RouteGuard` with its collaborators.
|
||||
2. `PathnameInterpreter` produces `{ locale, logicalPathname }`.
|
||||
3. `await guard.enforce({ logicalPathname, locale })`.
|
||||
3. Render children.
|
||||
|
||||
### 3.3 How matching works without hardcoded paths
|
||||
|
||||
When `RouteGuard` needs to answer questions like “is this an auth page?” or “does this require sponsor role?”, it should:
|
||||
|
||||
- Match `logicalPathname` against patterns from `RouteCatalog`.
|
||||
- Prefer the existing matcher logic in `routeMatchers` (see [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193)) so dynamic routes like `/leagues/[id]/settings` continue to work.
|
||||
|
||||
This keeps auth rules stable even if later:
|
||||
|
||||
- `/auth/login` becomes `/de/auth/login`
|
||||
- or `/anmelden` in German via a localized route mapping
|
||||
|
||||
because the matching happens against route IDs/patterns, not by string prefix checks.
|
||||
|
||||
### 3.4 Middleware becomes minimal (or removed)
|
||||
|
||||
After server layouts exist, middleware should either be:
|
||||
|
||||
- **Removed entirely**, or
|
||||
- Reduced to only performance/edge cases (static assets bypass, maybe public route list).
|
||||
|
||||
Important: middleware cannot reliably call backend session endpoint in all environments without complexity/cost; server layouts can.
|
||||
|
||||
### 3.5 Replace alpha mode with feature flags
|
||||
|
||||
Alpha mode branch currently in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed.
|
||||
|
||||
Target:
|
||||
|
||||
- Introduce a feature flags source (existing system in repo) and a small provider.
|
||||
- Feature flags decide:
|
||||
- which navigation items are shown
|
||||
- which pages/features are enabled
|
||||
- which UI shell is used (if we need an “alpha shell”, it’s just a flag)
|
||||
|
||||
Rules:
|
||||
|
||||
- Feature flags must not bypass auth/authorization.
|
||||
- Feature flags must be evaluated server-side for initial render, and optionally rehydrated client-side.
|
||||
|
||||
### 3.6 Demo user without logic exceptions
|
||||
|
||||
Replace “demo mode cookies” with:
|
||||
|
||||
- A standard login flow that returns a normal `gp_session` cookie.
|
||||
- Demo login endpoint remains acceptable in non-production, but it should:
|
||||
- authenticate as a *predefined seeded user*
|
||||
- return a normal session payload
|
||||
- set only `gp_session`
|
||||
- not set or depend on `gridpilot_demo_mode`, sponsor id/name cookies
|
||||
|
||||
Update all UI that reads `gridpilot_demo_mode` to read session role instead.
|
||||
|
||||
---
|
||||
|
||||
## 4) Migration plan (implementation sequence, class-driven)
|
||||
|
||||
This is ordered to keep tests green most of the time and reduce churn.
|
||||
|
||||
### Step 0 — Document and freeze behavior
|
||||
|
||||
- Confirm redirect semantics match integration tests:
|
||||
- unauthenticated protected → `/auth/login?returnTo=...`
|
||||
- wrong-role protected → same redirect
|
||||
- authenticated hitting `/auth/login` → redirect to role home (tests currently assert `/dashboard` or `/sponsor/dashboard`)
|
||||
|
||||
### Step 1 — Introduce the classes (incl. i18n-ready routing)
|
||||
|
||||
- Implement `RouteCatalog` + `RoutePathBuilder` first (removes hardcoded strings, enables i18n later).
|
||||
- Implement `PathnameInterpreter` (normalize pathnames).
|
||||
- Implement `RouteAccessPolicy` + `ReturnToSanitizer` next (pure logic, easy unit tests).
|
||||
- Implement `SessionGateway` (server-only).
|
||||
- Implement `AuthRedirectBuilder` (pure + uses sanitizer/policy).
|
||||
- Implement `RouteGuard` (composition).
|
||||
|
||||
### Step 2 — Convert protected layouts to server enforcement using `RouteGuard`
|
||||
|
||||
### Step 3 — Fix auth routes and redirects (server-first)
|
||||
|
||||
### Step 4 — Remove alpha mode branches and replace with `FeatureFlagService`
|
||||
|
||||
### Step 5 — Remove demo cookies and demo logic exceptions
|
||||
|
||||
### Step 6 — Simplify or delete middleware
|
||||
|
||||
- Remove all `gridpilot_demo_mode`, sponsor id/name cookies usage.
|
||||
- Ensure sponsor role is derived from session.
|
||||
|
||||
### Step 7 — Update integration tests
|
||||
|
||||
- If server layouts cover all protected routes, middleware can be deleted.
|
||||
- If kept, it should only do cheap routing (no role logic, no demo logic).
|
||||
|
||||
### Step 8 — Delete obsolete code + tighten tests
|
||||
|
||||
- Update cookie setup in [`tests/integration/website/websiteAuth.ts`](../tests/integration/website/websiteAuth.ts:1):
|
||||
- stop setting demo cookies
|
||||
- keep drift cookies if still supported by API
|
||||
- rely solely on `gp_session` from demo-login
|
||||
|
||||
- Update expectations in [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) only if necessary.
|
||||
|
||||
### Step 9 — Run repo verifications
|
||||
|
||||
- `eslint`
|
||||
- `tsc`
|
||||
- integration tests including [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1)
|
||||
|
||||
---
|
||||
|
||||
## 5) Files to remove (expected deletions)
|
||||
|
||||
These are the primary candidates to delete because they become redundant or incorrect under the new concept.
|
||||
|
||||
### 5.1 Website auth/route-protection code to delete
|
||||
|
||||
- [`apps/website/lib/guards/AuthGuard.tsx`](../apps/website/lib/guards/AuthGuard.tsx:1)
|
||||
- [`apps/website/lib/guards/RoleGuard.tsx`](../apps/website/lib/guards/RoleGuard.tsx:1)
|
||||
- [`apps/website/lib/guards/AuthGuard.test.tsx`](../apps/website/lib/guards/AuthGuard.test.tsx:1)
|
||||
- [`apps/website/lib/guards/RoleGuard.test.tsx`](../apps/website/lib/guards/RoleGuard.test.tsx:1)
|
||||
|
||||
Rationale: client-side guards are replaced by server-side enforcement in layouts.
|
||||
|
||||
### 5.2 Website Next route handlers that conflict with the canonical API auth flow
|
||||
|
||||
- [`apps/website/app/auth/iracing/start/route.ts`](../apps/website/app/auth/iracing/start/route.ts:1)
|
||||
- [`apps/website/app/auth/iracing/callback/route.ts`](../apps/website/app/auth/iracing/callback/route.ts:1)
|
||||
|
||||
Rationale: these are placeholder/mocks and should be replaced with a single canonical auth flow via the API.
|
||||
|
||||
### 5.3 Website logout route handler (currently incorrect)
|
||||
|
||||
- [`apps/website/app/auth/logout/route.ts`](../apps/website/app/auth/logout/route.ts:1)
|
||||
|
||||
Rationale: deletes `gp_demo_session` instead of `gp_session` and duplicates API logout.
|
||||
|
||||
### 5.4 Demo-cookie driven UI (to remove or refactor)
|
||||
|
||||
These files likely contain `gridpilot_demo_mode` logic and must be refactored to session-based logic; if purely demo-only, delete.
|
||||
|
||||
- [`apps/website/components/dev/DevToolbar.tsx`](../apps/website/components/dev/DevToolbar.tsx:1) (refactor: use session, not demo cookies)
|
||||
- [`apps/website/components/profile/UserPill.tsx`](../apps/website/components/profile/UserPill.tsx:1) (refactor)
|
||||
- [`apps/website/components/sponsors/SponsorInsightsCard.tsx`](../apps/website/components/sponsors/SponsorInsightsCard.tsx:1) (refactor)
|
||||
|
||||
Note: these are not guaranteed deletions, but demo-cookie logic in them must be removed.
|
||||
|
||||
### 5.5 Alpha mode (to remove)
|
||||
|
||||
- “Alpha mode” branching in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed.
|
||||
|
||||
Whether any specific “alpha-only” files are deleted depends on feature flag mapping; the hard requirement is: no `mode === 'alpha'` routing/auth exceptions remain.
|
||||
|
||||
---
|
||||
|
||||
## 6) Acceptance criteria
|
||||
|
||||
- There is exactly one canonical place where access is enforced: server layouts.
|
||||
- Middleware contains no auth/role/demo logic (or is deleted).
|
||||
- Auth logic has zero hardcoded pathname strings; it relies on route IDs + builders and is i18n-ready.
|
||||
- No code uses `gridpilot_demo_mode` or sponsor-id/name cookies to drive auth/redirect logic.
|
||||
- Demo login returns a normal session; “demo user” behaves like any other user.
|
||||
- Alpha mode is removed; feature flags are used instead.
|
||||
- Integration tests under [`tests/integration/website`](../tests/integration/website/auth-flow.test.ts:1) pass.
|
||||
- Repo checks pass: eslint + tsc + tests.
|
||||
@@ -1,289 +0,0 @@
|
||||
# Plan: Remove demo-login logic; use seed-only predefined demo users
|
||||
|
||||
## Goal
|
||||
|
||||
Replace current demo-login feature (custom endpoint + special-case behavior) with **predefined demo users created by seeding only**.
|
||||
|
||||
Constraints from request:
|
||||
|
||||
* No extra demo-login code in “core” or “website” (beyond normal email+password login).
|
||||
* Demo users exist because the seed created them.
|
||||
* Remove role/ID hacks and mock branches that exist only for demo-login.
|
||||
|
||||
## Current demo-login touchpoints to remove / refactor
|
||||
|
||||
### API (Nest)
|
||||
|
||||
* Demo login use case and wiring:
|
||||
* [`apps/api/src/development/use-cases/DemoLoginUseCase.ts`](apps/api/src/development/use-cases/DemoLoginUseCase.ts)
|
||||
* Demo login endpoint:
|
||||
* [`apps/api/src/domain/auth/AuthController.ts`](apps/api/src/domain/auth/AuthController.ts)
|
||||
* Demo login method in service:
|
||||
* [`apps/api/src/domain/auth/AuthService.ts`](apps/api/src/domain/auth/AuthService.ts)
|
||||
* Demo login providers / presenter injection:
|
||||
* [`apps/api/src/domain/auth/AuthProviders.ts`](apps/api/src/domain/auth/AuthProviders.ts)
|
||||
* [`apps/api/src/domain/auth/presenters/DemoLoginPresenter.ts`](apps/api/src/domain/auth/presenters/DemoLoginPresenter.ts)
|
||||
* Demo login DTO type:
|
||||
* [`apps/api/src/domain/auth/dtos/AuthDto.ts`](apps/api/src/domain/auth/dtos/AuthDto.ts)
|
||||
* Production guard special-case:
|
||||
* [`apps/api/src/domain/auth/ProductionGuard.ts`](apps/api/src/domain/auth/ProductionGuard.ts)
|
||||
* Dashboard “demo user” mock branch:
|
||||
* [`apps/api/src/domain/dashboard/DashboardService.ts`](apps/api/src/domain/dashboard/DashboardService.ts)
|
||||
|
||||
### Website
|
||||
|
||||
* Demo login UI and calls:
|
||||
* Login page demo button calls demo-login:
|
||||
* [`apps/website/app/auth/login/page.tsx`](apps/website/app/auth/login/page.tsx)
|
||||
* Sponsor signup “demo” flow calls demo-login:
|
||||
* [`apps/website/app/sponsor/signup/page.tsx`](apps/website/app/sponsor/signup/page.tsx)
|
||||
* DevToolbar demo login section calls demo-login and infers role from email patterns:
|
||||
* [`apps/website/components/dev/DevToolbar.tsx`](apps/website/components/dev/DevToolbar.tsx)
|
||||
* Client API/types:
|
||||
* [`apps/website/lib/api/auth/AuthApiClient.ts`](apps/website/lib/api/auth/AuthApiClient.ts)
|
||||
* Generated demo DTO type:
|
||||
* [`apps/website/lib/types/generated/DemoLoginDTO.ts`](apps/website/lib/types/generated/DemoLoginDTO.ts)
|
||||
|
||||
### Tests
|
||||
|
||||
* Smoke/integration helpers fetch demo-login to obtain cookies:
|
||||
* [`tests/smoke/websiteAuth.ts`](tests/smoke/websiteAuth.ts)
|
||||
* [`tests/integration/website/websiteAuth.ts`](tests/integration/website/websiteAuth.ts)
|
||||
* Integration tests asserting demo-login endpoint:
|
||||
* [`tests/integration/website/auth-flow.test.ts`](tests/integration/website/auth-flow.test.ts)
|
||||
* Test docker compose enables demo-login:
|
||||
* [`docker-compose.test.yml`](docker-compose.test.yml)
|
||||
|
||||
### Core
|
||||
|
||||
* There is a demo identity provider type in core:
|
||||
* [`core/identity/application/ports/IdentityProviderPort.ts`](core/identity/application/ports/IdentityProviderPort.ts)
|
||||
* Keep or remove depends on whether it’s a real abstraction used outside demo-login.
|
||||
|
||||
## Proposed clean solution (seed-only)
|
||||
|
||||
### 1) Define the canonical demo accounts (single source of truth)
|
||||
|
||||
We will define a fixed set of demo users with:
|
||||
|
||||
* fixed email addresses (already used in demo-login)
|
||||
* one fixed password (user-approved): `Demo1234!`
|
||||
* stable user IDs (so other seeded objects can reference them) — **important to remove the need for prefix heuristics**
|
||||
|
||||
Recommended roles/emails (existing patterns):
|
||||
|
||||
* driver: `demo.driver@example.com`
|
||||
* sponsor: `demo.sponsor@example.com`
|
||||
* league-owner: `demo.owner@example.com`
|
||||
* league-steward: `demo.steward@example.com`
|
||||
* league-admin: `demo.admin@example.com`
|
||||
* system-owner: `demo.systemowner@example.com`
|
||||
* super-admin: `demo.superadmin@example.com`
|
||||
|
||||
IDs:
|
||||
|
||||
* Prefer deterministic IDs via existing seed ID helpers, e.g. [`adapters/bootstrap/racing/SeedIdHelper.ts`](adapters/bootstrap/racing/SeedIdHelper.ts)
|
||||
* Decide whether the **session id** should be `userId` vs `primaryDriverId` and enforce that consistently.
|
||||
|
||||
### 2) Seed creation: add demo users to the bootstrap seed path
|
||||
|
||||
We already have a robust bootstrapping / seed mechanism:
|
||||
|
||||
* API bootstraps racing data via [`apps/api/src/domain/bootstrap/BootstrapModule.ts`](apps/api/src/domain/bootstrap/BootstrapModule.ts) and [`adapters/bootstrap/SeedRacingData.ts`](adapters/bootstrap/SeedRacingData.ts)
|
||||
|
||||
Plan:
|
||||
|
||||
* Add an **identity seed step** that creates the demo users (and any required linked domain objects like sponsor account/admin user rows).
|
||||
* Make it **idempotent**: create if missing, update if mismatched (or delete+recreate under force reseed).
|
||||
* Ensure it runs in:
|
||||
* `NODE_ENV=test` (so tests can login normally)
|
||||
* `inmemory` persistence (dev default)
|
||||
* postgres non-production when bootstrap is enabled (consistent with current bootstrap approach)
|
||||
|
||||
### 3) Remove demo-login endpoint and all supporting glue
|
||||
|
||||
Delete/cleanup:
|
||||
|
||||
* API: `POST /auth/demo-login` and use case/presenter/provider wiring.
|
||||
* Env flags: remove `ALLOW_DEMO_LOGIN` usage.
|
||||
* Website: remove demo login calls and any demo-login generated DTO.
|
||||
* Tests: stop calling demo-login.
|
||||
|
||||
Result: demo login becomes “type email + password (Demo1234!)” like any other login.
|
||||
|
||||
### 4) Remove “demo user” hacks (mock branches, role heuristics)
|
||||
|
||||
Key removals:
|
||||
|
||||
* DashboardService demo mock branch in [`apps/api/src/domain/dashboard/DashboardService.ts`](apps/api/src/domain/dashboard/DashboardService.ts)
|
||||
* Replace with real data from seeded racing entities.
|
||||
* If a demo role needs different dashboard shape, that should come from real seeded data + permissions, not hardcoded `driverId.startsWith(...)`.
|
||||
|
||||
* Website DevToolbar role inference based on email substrings in [`apps/website/components/dev/DevToolbar.tsx`](apps/website/components/dev/DevToolbar.tsx)
|
||||
* With seed-only demo users, the toolbar doesn’t need to guess; it can just show the current session.
|
||||
|
||||
### 5) Update tests to use normal login
|
||||
|
||||
Replace demo-login cookie setup with:
|
||||
|
||||
* Ensure demo users exist (seed ran in test environment)
|
||||
* Call the normal login endpoint (API or Next.js rewrite) to get a real `gp_session` cookie
|
||||
|
||||
Targets:
|
||||
|
||||
* [`tests/smoke/websiteAuth.ts`](tests/smoke/websiteAuth.ts)
|
||||
* [`tests/integration/website/websiteAuth.ts`](tests/integration/website/websiteAuth.ts)
|
||||
* Any tests that assert demo-login behavior should be rewritten to assert seeded login behavior.
|
||||
|
||||
Docker test stack:
|
||||
|
||||
* Remove `ALLOW_DEMO_LOGIN=true` from [`docker-compose.test.yml`](docker-compose.test.yml)
|
||||
* Ensure bootstrap+seed runs for identity in test.
|
||||
|
||||
## Architecture alignment (docs/architecture)
|
||||
|
||||
This plan aligns with the principles in:
|
||||
|
||||
* “API is source of truth; client is UX only”:
|
||||
* [`docs/architecture/QUICK_AUTH_REFERENCE.md`](docs/architecture/QUICK_AUTH_REFERENCE.md)
|
||||
* Avoid hardcoded special cases and unpredictable flows:
|
||||
* [`docs/architecture/CLEAN_AUTH_SOLUTION.md`](docs/architecture/CLEAN_AUTH_SOLUTION.md)
|
||||
* [`docs/architecture/UNIFIED_AUTH_CONCEPT.md`](docs/architecture/UNIFIED_AUTH_CONCEPT.md)
|
||||
|
||||
Demo users are data, not behavior.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Subtasks
|
||||
|
||||
1. **Add demo-user seed module (idempotent) to bootstrap** - COMPLETED
|
||||
- Created `SeedDemoUsers` class in `adapters/bootstrap/SeedDemoUsers.ts`
|
||||
- Defined 7 demo users with fixed emails and password `Demo1234!`
|
||||
- Implemented idempotent creation/update logic
|
||||
- Added deterministic ID generation
|
||||
|
||||
2. **Wire seed into API startup path** - COMPLETED
|
||||
- Integrated `SeedDemoUsers` into `BootstrapModule`
|
||||
- Added conditional seeding logic (dev/test only, respects bootstrap flags)
|
||||
- Added force reseed support via `GRIDPILOT_API_FORCE_RESEED`
|
||||
|
||||
3. **Delete demo-login endpoint and supporting code** - COMPLETED
|
||||
- Removed `POST /auth/demo-login` endpoint
|
||||
- Removed `DemoLoginUseCase` and related presenters/providers
|
||||
- Removed `ALLOW_DEMO_LOGIN` environment variable usage
|
||||
- Cleaned up production guard special-cases
|
||||
|
||||
4. **Remove dashboard demo mock branch** - COMPLETED
|
||||
- Removed demo user mock branch from `DashboardService`
|
||||
- Dashboard now uses real seeded data
|
||||
|
||||
5. **Remove website demo-login UI and API client methods** - COMPLETED
|
||||
- Removed demo login button from login page
|
||||
- Removed demo flow from sponsor signup
|
||||
- Cleaned up DevToolbar demo login section
|
||||
- Removed demo-login API client methods and types
|
||||
|
||||
6. **Update tests to use normal login** - COMPLETED
|
||||
- Updated smoke tests to use seeded credentials
|
||||
- Updated integration tests to use normal login
|
||||
- Removed demo-login endpoint assertions
|
||||
- Updated test docker compose to remove `ALLOW_DEMO_LOGIN`
|
||||
|
||||
7. **Update docs to describe demo accounts + seeding** - COMPLETED
|
||||
- Created `docs/DEMO_ACCOUNTS.md` as single source of truth
|
||||
- Updated any existing docs with demo-login references
|
||||
- Documented environment variables and usage
|
||||
|
||||
8. **Verify: eslint, tsc, unit tests, integration tests** - COMPLETED
|
||||
- All code changes follow project standards
|
||||
- TypeScript types are correct
|
||||
- Tests updated to match new behavior
|
||||
|
||||
### Summary of Accomplishments
|
||||
|
||||
**What was removed:**
|
||||
- Custom demo-login endpoint (`/api/auth/demo-login`)
|
||||
- `ALLOW_DEMO_LOGIN` environment variable
|
||||
- Demo-login use case, presenters, and providers
|
||||
- Demo user mock branches in DashboardService
|
||||
- Demo login UI buttons and flows in website
|
||||
- Demo-login specific test helpers and assertions
|
||||
|
||||
**What was added:**
|
||||
- `SeedDemoUsers` class for creating demo users during bootstrap
|
||||
- 7 predefined demo users with fixed emails and `Demo1234!` password
|
||||
- `GRIDPILOT_API_FORCE_RESEED` environment variable for reseeding
|
||||
- `docs/DEMO_ACCOUNTS.md` documentation
|
||||
- Idempotent demo user creation logic
|
||||
|
||||
**How it works now:**
|
||||
1. Demo users are created automatically during API startup (dev/test only)
|
||||
2. Users log in with normal email/password flow
|
||||
3. No special demo-login code exists anywhere
|
||||
4. Demo users have stable IDs and proper roles
|
||||
5. All authentication flows use the same code path
|
||||
|
||||
### Remaining Work
|
||||
|
||||
None identified. The demo-login feature has been completely replaced with seed-only demo users.
|
||||
|
||||
### Architecture Alignment
|
||||
|
||||
This implementation follows the principles:
|
||||
- **Single source of truth**: Demo accounts defined in one place (`SeedDemoUsers`)
|
||||
- **No special cases**: Demo users are regular users created by seeding
|
||||
- **Clean separation**: Authentication logic unchanged, only data initialization added
|
||||
- **Environment-aware**: Demo users only in dev/test, never production
|
||||
- **Idempotent**: Safe to run multiple times, respects force reseed flag
|
||||
|
||||
### Files Created
|
||||
|
||||
- `docs/DEMO_ACCOUNTS.md` - Complete documentation for demo accounts
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `adapters/bootstrap/SeedDemoUsers.ts` - Demo user seed implementation
|
||||
- `apps/api/src/domain/bootstrap/BootstrapModule.ts` - Integrated demo user seeding
|
||||
- `apps/api/src/domain/bootstrap/BootstrapProviders.ts` - Added demo user seed provider
|
||||
- `tests/smoke/websiteAuth.ts` - Updated to use seeded login
|
||||
- `tests/integration/website/websiteAuth.ts` - Updated to use seeded login
|
||||
- `tests/integration/website/auth-flow.test.ts` - Updated to test seeded login
|
||||
- `docker-compose.test.yml` - Removed ALLOW_DEMO_LOGIN
|
||||
- `plans/2026-01-03_demo-users-seed-only-plan.md` - This document updated
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**New:**
|
||||
- `GRIDPILOT_API_FORCE_RESEED` - Force reseed demo users
|
||||
|
||||
**Removed:**
|
||||
- `ALLOW_DEMO_LOGIN` - No longer needed
|
||||
|
||||
**Existing (unchanged):**
|
||||
- `GRIDPILOT_API_BOOTSTRAP` - Controls all seeding
|
||||
- `GRIDPILOT_API_PERSISTENCE` - Database type
|
||||
- `NODE_ENV` - Environment mode
|
||||
|
||||
### Demo Users Available
|
||||
|
||||
All use password: `Demo1234!`
|
||||
|
||||
1. `demo.driver@example.com` - John Driver (user)
|
||||
2. `demo.sponsor@example.com` - Jane Sponsor (user)
|
||||
3. `demo.owner@example.com` - Alice Owner (owner)
|
||||
4. `demo.steward@example.com` - Bob Steward (user with admin)
|
||||
5. `demo.admin@example.com` - Charlie Admin (admin)
|
||||
6. `demo.systemowner@example.com` - Diana SystemOwner (admin)
|
||||
7. `demo.superadmin@example.com` - Edward SuperAdmin (admin)
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
✅ Demo accounts documentation exists
|
||||
✅ No references to demo-login endpoint in docs
|
||||
✅ No references to ALLOW_DEMO_LOGIN in docs
|
||||
✅ Plan document updated with completion status
|
||||
✅ All subtasks completed successfully
|
||||
✅ Architecture principles maintained
|
||||
✅ Tests updated and passing
|
||||
✅ Code follows project standards
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
# API-Driven Feature Configuration - Implementation Plan
|
||||
|
||||
## Goal
|
||||
Create a **single source of truth** for feature configuration that both API and Website use, eliminating duplication and ensuring security.
|
||||
|
||||
## Current State
|
||||
- **Website**: Uses `NEXT_PUBLIC_GRIDPILOT_MODE=alpha` with hardcoded feature list
|
||||
- **API**: Uses `NODE_ENV` with `features.config.ts`
|
||||
- **Problem**: Two systems, risk of inconsistency, security gaps
|
||||
|
||||
## Target State
|
||||
- **Single Source**: `apps/api/src/config/features.config.ts`
|
||||
- **Website**: Reads features from API endpoint
|
||||
- **Result**: One config file, automatic synchronization, secure by default
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create API Feature Endpoint
|
||||
|
||||
**File**: `apps/api/src/features/features.controller.ts`
|
||||
```typescript
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { loadFeatureConfig } from '../config/feature-loader';
|
||||
|
||||
@Controller('features')
|
||||
export class FeaturesController {
|
||||
@Get()
|
||||
async getFeatures() {
|
||||
const result = await loadFeatureConfig();
|
||||
return {
|
||||
features: result.features,
|
||||
loadedFrom: result.loadedFrom,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `apps/api/src/features/features.module.ts`
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeaturesController } from './features.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FeaturesController],
|
||||
})
|
||||
export class FeaturesModule {}
|
||||
```
|
||||
|
||||
**Update**: `apps/api/src/app.module.ts`
|
||||
```typescript
|
||||
import { FeaturesModule } from './features/features.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... existing modules
|
||||
FeaturesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
### Step 2: Update Website FeatureFlagService
|
||||
|
||||
**Replace**: `apps/website/lib/feature/FeatureFlagService.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* FeatureFlagService - Reads features from API
|
||||
*
|
||||
* Single Source of Truth: API /api/features endpoint
|
||||
*/
|
||||
|
||||
export class FeatureFlagService {
|
||||
private flags: Set<string>;
|
||||
|
||||
constructor(flags: string[]) {
|
||||
this.flags = new Set(flags);
|
||||
}
|
||||
|
||||
isEnabled(flag: string): boolean {
|
||||
return this.flags.has(flag);
|
||||
}
|
||||
|
||||
getEnabledFlags(): string[] {
|
||||
return Array.from(this.flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load features from API - Single Source of Truth
|
||||
*/
|
||||
static async fromAPI(): Promise<FeatureFlagService> {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const response = await fetch(`${baseUrl}/features`, {
|
||||
cache: 'no-store', // Always get fresh data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const flags = Object.keys(data.features).filter(
|
||||
key => data.features[key] === 'enabled'
|
||||
);
|
||||
|
||||
return new FeatureFlagService(flags);
|
||||
} catch (error) {
|
||||
console.error('Failed to load features from API:', error);
|
||||
// Fallback: empty service (secure by default)
|
||||
return new FeatureFlagService([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock for testing/local development
|
||||
*/
|
||||
static mock(flags: string[] = []): FeatureFlagService {
|
||||
return new FeatureFlagService(flags);
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side context interface
|
||||
export interface FeatureFlagContextType {
|
||||
isEnabled: (flag: string) => boolean;
|
||||
getEnabledFlags: () => string[];
|
||||
}
|
||||
|
||||
// Default mock for client-side when API unavailable
|
||||
export const mockFeatureFlags = new FeatureFlagService([
|
||||
'driver_profiles',
|
||||
'team_profiles',
|
||||
'wallets',
|
||||
'sponsors',
|
||||
'team_feature',
|
||||
'alpha_features'
|
||||
]);
|
||||
```
|
||||
|
||||
### Step 3: Update Website to Use API Features
|
||||
|
||||
**Update**: `apps/website/lib/services/home/getHomeData.ts`
|
||||
```typescript
|
||||
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||
|
||||
export async function getHomeData() {
|
||||
const container = ContainerManager.getInstance().getContainer();
|
||||
const sessionService = container.get<SessionService>(SESSION_SERVICE_TOKEN);
|
||||
const landingService = container.get<LandingService>(LANDING_SERVICE_TOKEN);
|
||||
|
||||
const session = await sessionService.getSession();
|
||||
if (session) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Load features from API (single source of truth)
|
||||
const featureService = await FeatureFlagService.fromAPI();
|
||||
const isAlpha = featureService.isEnabled('alpha_features');
|
||||
const discovery = await landingService.getHomeDiscovery();
|
||||
|
||||
return {
|
||||
isAlpha,
|
||||
upcomingRaces: discovery.upcomingRaces,
|
||||
topLeagues: discovery.topLeagues,
|
||||
teams: discovery.teams,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Update**: `apps/website/app/layout.tsx`
|
||||
```typescript
|
||||
// Remove: import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||
// Remove: const featureService = FeatureFlagService.fromEnv();
|
||||
// Remove: const enabledFlags = featureService.getEnabledFlags();
|
||||
|
||||
// Keep only: <FeatureFlagProvider flags={enabledFlags}>
|
||||
|
||||
// Instead, wrap the app to load features from API
|
||||
export default async function RootLayout({ children }) {
|
||||
// Load features from API
|
||||
const featureService = await FeatureFlagService.fromAPI();
|
||||
const enabledFlags = featureService.getEnabledFlags();
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<FeatureFlagProvider flags={enabledFlags}>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Remove Website-Specific Config
|
||||
|
||||
**Delete**: `apps/website/lib/mode.ts` (no longer needed)
|
||||
**Delete**: `apps/website/lib/feature/FeatureFlagService.ts` (replaced)
|
||||
**Delete**: `apps/website/lib/feature/FeatureFlagService.test.ts` (replaced)
|
||||
|
||||
**Update**: `apps/website/lib/config/env.ts`
|
||||
```typescript
|
||||
// Remove NEXT_PUBLIC_GRIDPILOT_MODE from schema
|
||||
const publicEnvSchema = z.object({
|
||||
// NEXT_PUBLIC_GRIDPILOT_MODE: z.enum(['pre-launch', 'alpha']).optional(), // REMOVE
|
||||
NEXT_PUBLIC_SITE_URL: urlOptional,
|
||||
NEXT_PUBLIC_API_BASE_URL: urlOptional,
|
||||
// ... other env vars
|
||||
});
|
||||
```
|
||||
|
||||
### Step 5: Update Environment Files
|
||||
|
||||
**Update**: `.env.development`
|
||||
```bash
|
||||
# Remove:
|
||||
# NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||
|
||||
# Keep:
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
**Update**: `.env.production`
|
||||
```bash
|
||||
# Remove:
|
||||
# NEXT_PUBLIC_GRIDPILOT_MODE=beta
|
||||
|
||||
# Keep:
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.gridpilot.com
|
||||
```
|
||||
|
||||
### Step 6: Update Tests
|
||||
|
||||
**Update**: `apps/api/src/config/integration.test.ts`
|
||||
```typescript
|
||||
// Add test for feature endpoint
|
||||
describe('Features API Endpoint', () => {
|
||||
it('should return all features from API', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
// Verify API returns all expected features
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
expect(result.features['alpha_features']).toBe('enabled');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Update**: `apps/website/lib/feature/FeatureFlagService.test.ts`
|
||||
```typescript
|
||||
// Test API loading
|
||||
describe('FeatureFlagService.fromAPI()', () => {
|
||||
it('should load features from API endpoint', async () => {
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: {
|
||||
'platform.dashboard': 'enabled',
|
||||
'alpha_features': 'enabled',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
expect(service.isEnabled('platform.dashboard')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 7: Update Documentation
|
||||
|
||||
**Update**: `docs/FEATURE_ARCHITECTURE.md`
|
||||
```markdown
|
||||
# Feature Architecture - API-Driven
|
||||
|
||||
## Single Source of Truth
|
||||
|
||||
**Location**: `apps/api/src/config/features.config.ts`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **API Config** defines all features and their states
|
||||
2. **API Endpoint** `/features` exposes current configuration
|
||||
3. **Website** loads features from API at runtime
|
||||
4. **PolicyService** uses API config for security enforcement
|
||||
|
||||
## Benefits
|
||||
|
||||
- ✅ One file to maintain
|
||||
- ✅ Automatic synchronization
|
||||
- ✅ Runtime feature changes
|
||||
- ✅ Security-first (API controls everything)
|
||||
- ✅ No duplication
|
||||
|
||||
## Feature States (Same as before)
|
||||
|
||||
- `enabled` = Fully available
|
||||
- `disabled` = Completely blocked
|
||||
- `coming_soon` = Visible but not functional
|
||||
- `hidden` = Invisible
|
||||
|
||||
## Environment Mapping
|
||||
|
||||
| Environment | API Config | Website Behavior |
|
||||
|-------------|------------|------------------|
|
||||
| Development | All enabled | Full platform access |
|
||||
| Test | All enabled | Full testing |
|
||||
| Staging | Controlled | Beta features visible |
|
||||
| Production | Stable only | Production features only |
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Create API endpoint
|
||||
- [ ] Update FeatureFlagService to use API
|
||||
- [ ] Update website components to use new service
|
||||
- [ ] Remove old website config files
|
||||
- [ ] Update environment files
|
||||
- [ ] Update tests
|
||||
- [ ] Update documentation
|
||||
- [ ] Test end-to-end
|
||||
- [ ] Verify security (no leaks)
|
||||
|
||||
## Expected Results
|
||||
|
||||
**Before**: Two configs, risk of mismatch, manual sync
|
||||
**After**: One config, automatic sync, secure by default
|
||||
|
||||
**Files Changed**: ~8 files
|
||||
**Files Deleted**: ~3 files
|
||||
**Time Estimate**: 2-3 hours
|
||||
@@ -1,401 +0,0 @@
|
||||
# Clean Architecture Violation Fix Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Problem**: The codebase violates Clean Architecture by having use cases call presenters directly (`this.output.present()`), creating tight coupling and causing "Presenter not presented" errors.
|
||||
|
||||
**Root Cause**: Use cases are doing the presenter's job instead of returning data and letting controllers handle the wiring.
|
||||
|
||||
**Solution**: Remove ALL `.present()` calls from use cases. Use cases return Results. Controllers wire Results to Presenters.
|
||||
|
||||
---
|
||||
|
||||
## The Violation Pattern
|
||||
|
||||
### ❌ Current Wrong Pattern (Violates Clean Architecture)
|
||||
```typescript
|
||||
// core/racing/application/use-cases/GetRaceDetailUseCase.ts
|
||||
class GetRaceDetailUseCase {
|
||||
constructor(
|
||||
private repositories: any,
|
||||
private output: UseCaseOutputPort<GetRaceDetailResult> // ❌ Wrong
|
||||
) {}
|
||||
|
||||
async execute(input: GetRaceDetailInput): Promise<Result<void, ApplicationError>> {
|
||||
const race = await this.raceRepository.findById(input.raceId);
|
||||
|
||||
if (!race) {
|
||||
const result = Result.err({ code: 'RACE_NOT_FOUND', details: {...} });
|
||||
this.output.present(result); // ❌ WRONG: Use case calling presenter
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = Result.ok({ race, league, registrations, drivers, userResult, isUserRegistered, canRegister });
|
||||
this.output.present(result); // ❌ WRONG: Use case calling presenter
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct Pattern (Clean Architecture)
|
||||
```typescript
|
||||
// core/racing/application/use-cases/GetRaceDetailUseCase.ts
|
||||
class GetRaceDetailUseCase {
|
||||
constructor(
|
||||
private repositories: any,
|
||||
// NO output port - removed
|
||||
) {}
|
||||
|
||||
async execute(input: GetRaceDetailInput): Promise<Result<GetRaceDetailResult, ApplicationError>> {
|
||||
const race = await this.raceRepository.findById(input.raceId);
|
||||
|
||||
if (!race) {
|
||||
return Result.err({ code: 'RACE_NOT_FOUND', details: {...} });
|
||||
// ✅ No .present() call
|
||||
}
|
||||
|
||||
return Result.ok({ race, league, registrations, drivers, userResult, isUserRegistered, canRegister });
|
||||
// ✅ No .present() call
|
||||
}
|
||||
}
|
||||
|
||||
// apps/api/src/domain/race/RaceService.ts (Controller layer)
|
||||
class RaceService {
|
||||
constructor(
|
||||
private getRaceDetailUseCase: GetRaceDetailUseCase,
|
||||
private raceDetailPresenter: RaceDetailPresenter,
|
||||
) {}
|
||||
|
||||
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailPresenter> {
|
||||
const result = await this.getRaceDetailUseCase.execute(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new NotFoundException(result.error.details.message);
|
||||
}
|
||||
|
||||
this.raceDetailPresenter.present(result.value); // ✅ Controller wires to presenter
|
||||
return this.raceDetailPresenter;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Needs To Be Done
|
||||
|
||||
### Phase 1: Fix Use Cases (Remove Output Ports)
|
||||
|
||||
**Files to modify in `core/racing/application/use-cases/`:**
|
||||
|
||||
1. **GetRaceDetailUseCase.ts** (lines 35-44, 46-115)
|
||||
- Remove `output: UseCaseOutputPort<GetRaceDetailResult>` from constructor
|
||||
- Change return type from `Promise<Result<void, ApplicationError>>` to `Promise<Result<GetRaceDetailResult, ApplicationError>>`
|
||||
- Remove all `this.output.present()` calls (lines 100, 109-112)
|
||||
|
||||
2. **GetRaceRegistrationsUseCase.ts** (lines 27-29, 31-70)
|
||||
- Remove output port from constructor
|
||||
- Change return type
|
||||
- Remove `this.output.present()` calls (lines 40-43, 66-69)
|
||||
|
||||
3. **GetLeagueFullConfigUseCase.ts** (lines 35-37, 39-92)
|
||||
- Remove output port from constructor
|
||||
- Change return type
|
||||
- Remove `this.output.present()` calls (lines 47-50, 88-91)
|
||||
|
||||
4. **GetRaceWithSOFUseCase.ts** (lines 43-45, 47-118)
|
||||
- Remove output port from constructor
|
||||
- Change return type
|
||||
- Remove `this.output.present()` calls (lines 58-61, 114-117)
|
||||
|
||||
5. **GetRaceResultsDetailUseCase.ts** (lines 41-43, 45-100)
|
||||
- Remove output port from constructor
|
||||
- Change return type
|
||||
- Remove `this.output.present()` calls (lines 56-59, 95-98)
|
||||
|
||||
**Continue this pattern for ALL 150+ use cases listed in your original analysis.**
|
||||
|
||||
### Phase 2: Fix Controllers/Services (Add Wiring Logic)
|
||||
|
||||
**Files to modify in `apps/api/src/domain/`:**
|
||||
|
||||
1. **RaceService.ts** (lines 135-139)
|
||||
- Update `getRaceDetail()` to wire use case result to presenter
|
||||
- Add error handling for Result.Err cases
|
||||
|
||||
2. **RaceProviders.ts** (lines 138-144, 407-437)
|
||||
- Remove adapter classes that wrap presenters
|
||||
- Update provider factories to inject presenters directly to controllers
|
||||
- Remove `RaceDetailOutputAdapter` and similar classes
|
||||
|
||||
3. **All other service files** that use use cases
|
||||
- Update method signatures to handle Results
|
||||
- Add proper error mapping
|
||||
- Wire results to presenters
|
||||
|
||||
### Phase 3: Update Module Wiring
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
1. **RaceProviders.ts** (lines 287-779)
|
||||
- Remove all adapter classes (lines 111-285)
|
||||
- Update provider definitions to not use adapters
|
||||
- Simplify dependency injection
|
||||
|
||||
2. **All other provider files** in `apps/api/src/domain/*/`
|
||||
- Remove adapter patterns
|
||||
- Update DI containers
|
||||
|
||||
### Phase 4: Fix Presenters (If Needed)
|
||||
|
||||
**Some presenters may need updates:**
|
||||
|
||||
1. **RaceDetailPresenter.ts** (lines 15-26, 28-114)
|
||||
- Ensure `present()` method accepts `GetRaceDetailResult` directly
|
||||
- No changes needed if already correct
|
||||
|
||||
2. **CommandResultPresenter.ts** and similar
|
||||
- Ensure they work with Results from controllers, not use cases
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### For Each Use Case File:
|
||||
- [ ] Remove `output: UseCaseOutputPort<T>` from constructor
|
||||
- [ ] Change return type from `Promise<Result<void, E>>` to `Promise<Result<T, E>>`
|
||||
- [ ] Remove all `this.output.present()` calls
|
||||
- [ ] Return Result directly
|
||||
- [ ] Update imports if needed
|
||||
|
||||
### For Each Controller/Service File:
|
||||
- [ ] Update methods to call use case and get Result
|
||||
- [ ] Add `if (result.isErr())` error handling
|
||||
- [ ] Call `presenter.present(result.value)` after success
|
||||
- [ ] Return presenter or ViewModel
|
||||
- [ ] Remove adapter usage
|
||||
|
||||
### For Each Provider File:
|
||||
- [ ] Remove adapter classes
|
||||
- [ ] Update DI to inject presenters to controllers
|
||||
- [ ] Simplify provider definitions
|
||||
|
||||
---
|
||||
|
||||
## Files That Need Immediate Attention
|
||||
|
||||
### High Priority (Core Racing Domain):
|
||||
```
|
||||
core/racing/application/use-cases/GetRaceDetailUseCase.ts
|
||||
core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts
|
||||
core/racing/application/use-cases/GetRaceWithSOFUseCase.ts
|
||||
core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts
|
||||
core/racing/application/use-cases/CompleteRaceUseCase.ts
|
||||
core/racing/application/use-cases/ApplyPenaltyUseCase.ts
|
||||
core/racing/application/use-cases/JoinLeagueUseCase.ts
|
||||
core/racing/application/use-cases/JoinTeamUseCase.ts
|
||||
core/racing/application/use-cases/RegisterForRaceUseCase.ts
|
||||
core/racing/application/use-cases/WithdrawFromRaceUseCase.ts
|
||||
core/racing/application/use-cases/CancelRaceUseCase.ts
|
||||
core/racing/application/use-cases/ReopenRaceUseCase.ts
|
||||
core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts
|
||||
core/racing/application/use-cases/ImportRaceResultsUseCase.ts
|
||||
core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts
|
||||
core/racing/application/use-cases/FileProtestUseCase.ts
|
||||
core/racing/application/use-cases/ReviewProtestUseCase.ts
|
||||
core/racing/application/use-cases/QuickPenaltyUseCase.ts
|
||||
core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts
|
||||
core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts
|
||||
core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts
|
||||
core/racing/application/use-cases/GetSponsorDashboardUseCase.ts
|
||||
core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts
|
||||
core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts
|
||||
core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts
|
||||
core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.ts
|
||||
core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueWalletUseCase.ts
|
||||
core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueStatsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts
|
||||
core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueRosterMembersUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueRosterJoinRequestsUseCase.ts
|
||||
core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts
|
||||
core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts
|
||||
core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts
|
||||
core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts
|
||||
core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueAdminUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts
|
||||
core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueScheduleUseCase.ts
|
||||
core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.ts
|
||||
core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase.ts
|
||||
core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase.ts
|
||||
core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase.ts
|
||||
core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.ts
|
||||
core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts
|
||||
core/racing/application/use-cases/GetSeasonDetailsUseCase.ts
|
||||
core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts
|
||||
core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts
|
||||
core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts
|
||||
core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts
|
||||
core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueStandingsUseCase.ts
|
||||
core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts
|
||||
core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts
|
||||
core/racing/application/use-cases/GetTotalDriversUseCase.ts
|
||||
core/racing/application/use-cases/GetTotalLeaguesUseCase.ts
|
||||
core/racing/application/use-cases/GetTotalRacesUseCase.ts
|
||||
core/racing/application/use-cases/GetAllRacesUseCase.ts
|
||||
core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts
|
||||
core/racing/application/use-cases/GetRacesPageDataUseCase.ts
|
||||
core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts
|
||||
core/racing/application/use-cases/GetAllTeamsUseCase.ts
|
||||
core/racing/application/use-cases/GetTeamDetailsUseCase.ts
|
||||
core/racing/application/use-cases/GetTeamMembersUseCase.ts
|
||||
core/racing/application/use-cases/UpdateTeamUseCase.ts
|
||||
core/racing/application/use-cases/CreateTeamUseCase.ts
|
||||
core/racing/application/use-cases/LeaveTeamUseCase.ts
|
||||
core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts
|
||||
core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts
|
||||
core/racing/application/use-cases/GetDriverTeamUseCase.ts
|
||||
core/racing/application/use-cases/GetProfileOverviewUseCase.ts
|
||||
core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts
|
||||
core/racing/application/use-cases/UpdateDriverProfileUseCase.ts
|
||||
core/racing/application/use-cases/SendFinalResultsUseCase.ts
|
||||
core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts
|
||||
core/racing/application/use-cases/RequestProtestDefenseUseCase.ts
|
||||
core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts
|
||||
core/racing/application/use-cases/GetRaceProtestsUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueProtestsUseCase.ts
|
||||
core/racing/application/use-cases/GetRacePenaltiesUseCase.ts
|
||||
core/racing/application/use-cases/GetSponsorsUseCase.ts
|
||||
core/racing/application/use-cases/GetSponsorUseCase.ts
|
||||
core/racing/application/use-cases/CreateSponsorUseCase.ts
|
||||
core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts
|
||||
core/racing/application/use-cases/GetLeagueStatsUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Media Domain):
|
||||
```
|
||||
core/media/application/use-cases/GetAvatarUseCase.ts
|
||||
core/media/application/use-cases/GetMediaUseCase.ts
|
||||
core/media/application/use-cases/DeleteMediaUseCase.ts
|
||||
core/media/application/use-cases/UploadMediaUseCase.ts
|
||||
core/media/application/use-cases/UpdateAvatarUseCase.ts
|
||||
core/media/application/use-cases/RequestAvatarGenerationUseCase.ts
|
||||
core/media/application/use-cases/SelectAvatarUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Identity Domain):
|
||||
```
|
||||
core/identity/application/use-cases/SignupUseCase.ts
|
||||
core/identity/application/use-cases/SignupWithEmailUseCase.ts
|
||||
core/identity/application/use-cases/LoginUseCase.ts
|
||||
core/identity/application/use-cases/LoginWithEmailUseCase.ts
|
||||
core/identity/application/use-cases/ForgotPasswordUseCase.ts
|
||||
core/identity/application/use-cases/ResetPasswordUseCase.ts
|
||||
core/identity/application/use-cases/GetCurrentSessionUseCase.ts
|
||||
core/identity/application/use-cases/GetCurrentUserSessionUseCase.ts
|
||||
core/identity/application/use-cases/LogoutUseCase.ts
|
||||
core/identity/application/use-cases/StartAuthUseCase.ts
|
||||
core/identity/application/use-cases/HandleAuthCallbackUseCase.ts
|
||||
core/identity/application/use-cases/SignupSponsorUseCase.ts
|
||||
core/identity/application/use-cases/CreateAchievementUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Notifications Domain):
|
||||
```
|
||||
core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts
|
||||
core/notifications/application/use-cases/MarkNotificationReadUseCase.ts
|
||||
core/notifications/application/use-cases/NotificationPreferencesUseCases.ts
|
||||
core/notifications/application/use-cases/SendNotificationUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Analytics Domain):
|
||||
```
|
||||
core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts
|
||||
core/analytics/application/use-cases/GetDashboardDataUseCase.ts
|
||||
core/analytics/application/use-cases/RecordPageViewUseCase.ts
|
||||
core/analytics/application/use-cases/RecordEngagementUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Admin Domain):
|
||||
```
|
||||
core/admin/application/use-cases/ListUsersUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Social Domain):
|
||||
```
|
||||
core/social/application/use-cases/GetUserFeedUseCase.ts
|
||||
core/social/application/use-cases/GetCurrentUserSocialUseCase.ts
|
||||
```
|
||||
|
||||
### Medium Priority (Payments Domain):
|
||||
```
|
||||
core/payments/application/use-cases/GetWalletUseCase.ts
|
||||
core/payments/application/use-cases/GetMembershipFeesUseCase.ts
|
||||
core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts
|
||||
core/payments/application/use-cases/AwardPrizeUseCase.ts
|
||||
core/payments/application/use-cases/DeletePrizeUseCase.ts
|
||||
core/payments/application/use-cases/CreatePrizeUseCase.ts
|
||||
core/payments/application/use-cases/CreatePaymentUseCase.ts
|
||||
core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts
|
||||
core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts
|
||||
core/payments/application/use-cases/GetPaymentsUseCase.ts
|
||||
core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts
|
||||
```
|
||||
|
||||
### Controller/Service Files:
|
||||
```
|
||||
apps/api/src/domain/race/RaceService.ts
|
||||
apps/api/src/domain/race/RaceProviders.ts
|
||||
apps/api/src/domain/sponsor/SponsorService.ts
|
||||
apps/api/src/domain/league/LeagueService.ts
|
||||
apps/api/src/domain/driver/DriverService.ts
|
||||
apps/api/src/domain/auth/AuthService.ts
|
||||
apps/api/src/domain/analytics/AnalyticsService.ts
|
||||
apps/api/src/domain/notifications/NotificationsService.ts
|
||||
apps/api/src/domain/payments/PaymentsService.ts
|
||||
apps/api/src/domain/admin/AdminService.ts
|
||||
apps/api/src/domain/social/SocialService.ts
|
||||
apps/api/src/domain/media/MediaService.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **All use cases return `Result<T, E>` directly**
|
||||
✅ **No use case calls `.present()`**
|
||||
✅ **All controllers wire Results to Presenters**
|
||||
✅ **All adapter classes removed**
|
||||
✅ **Module wiring simplified**
|
||||
✅ **"Presenter not presented" errors eliminated**
|
||||
✅ **Tests updated and passing**
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- **150+ use cases** to fix
|
||||
- **20+ controller/service files** to update
|
||||
- **10+ provider files** to simplify
|
||||
- **Estimated time**: 2-3 days of focused work
|
||||
- **Risk**: Medium (requires careful testing)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Start with Phase 1**: Fix the core racing use cases first (highest impact)
|
||||
2. **Test each change**: Run existing tests to ensure no regressions
|
||||
3. **Update controllers**: Wire Results to Presenters
|
||||
4. **Simplify providers**: Remove adapter classes
|
||||
5. **Run full test suite**: Verify everything works
|
||||
|
||||
**This plan provides the roadmap to achieve 100% Clean Architecture compliance.**
|
||||
512
plans/DI_PLAN.md
512
plans/DI_PLAN.md
@@ -1,512 +0,0 @@
|
||||
# Dependency Injection Plan for GridPilot Website
|
||||
|
||||
## Overview
|
||||
|
||||
Implement proper dependency injection in your website using InversifyJS, following the same patterns as your NestJS API. This replaces the current manual `ServiceFactory` approach with a professional DI container system.
|
||||
|
||||
## Why InversifyJS?
|
||||
|
||||
- **NestJS-like**: Same decorators and patterns you already know
|
||||
- **TypeScript-first**: Excellent type safety
|
||||
- **React-friendly**: Works seamlessly with React Context
|
||||
- **Production-ready**: Battle-tested, well-maintained
|
||||
|
||||
## Current Problem
|
||||
|
||||
```typescript
|
||||
// Current: Manual dependency management
|
||||
// apps/website/lib/services/ServiceProvider.tsx
|
||||
const services = useMemo(() => {
|
||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
||||
return {
|
||||
leagueService: serviceFactory.createLeagueService(),
|
||||
driverService: serviceFactory.createDriverService(),
|
||||
// ... 25+ services manually created
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
- No formal DI container
|
||||
- Manual dependency wiring
|
||||
- Hard to test/mock
|
||||
- No lifecycle management
|
||||
- Inconsistent with API
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install inversify reflect-metadata
|
||||
npm install --save-dev @types/inversify
|
||||
```
|
||||
|
||||
### 2. Configure TypeScript
|
||||
|
||||
```json
|
||||
// apps/website/tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Core Structure
|
||||
|
||||
```
|
||||
apps/website/lib/di/
|
||||
├── index.ts # Main exports
|
||||
├── container.ts # Container factory
|
||||
├── tokens.ts # Symbol tokens
|
||||
├── providers/
|
||||
│ └── ContainerProvider.tsx
|
||||
├── hooks/
|
||||
│ └── useInject.ts
|
||||
└── modules/
|
||||
├── core.module.ts
|
||||
├── api.module.ts
|
||||
├── league.module.ts
|
||||
└── ... (one per domain)
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Token Registry
|
||||
|
||||
**File**: `apps/website/lib/di/tokens.ts`
|
||||
|
||||
```typescript
|
||||
// Centralized token registry
|
||||
export const LOGGER_TOKEN = Symbol.for('Core.Logger');
|
||||
export const ERROR_REPORTER_TOKEN = Symbol.for('Core.ErrorReporter');
|
||||
|
||||
export const LEAGUE_SERVICE_TOKEN = Symbol.for('Service.League');
|
||||
export const DRIVER_SERVICE_TOKEN = Symbol.for('Service.Driver');
|
||||
export const TEAM_SERVICE_TOKEN = Symbol.for('Service.Team');
|
||||
export const RACE_SERVICE_TOKEN = Symbol.for('Service.Race');
|
||||
// ... all service tokens
|
||||
```
|
||||
|
||||
### Step 2: Container Factory
|
||||
|
||||
**File**: `apps/website/lib/di/container.ts`
|
||||
|
||||
```typescript
|
||||
import { Container } from 'inversify';
|
||||
import { CoreModule } from './modules/core.module';
|
||||
import { ApiModule } from './modules/api.module';
|
||||
import { LeagueModule } from './modules/league.module';
|
||||
// ... other modules
|
||||
|
||||
export function createContainer(): Container {
|
||||
const container = new Container({ defaultScope: 'Singleton' });
|
||||
|
||||
container.load(
|
||||
CoreModule,
|
||||
ApiModule,
|
||||
LeagueModule,
|
||||
// ... all modules
|
||||
);
|
||||
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: React Integration
|
||||
|
||||
**File**: `apps/website/lib/di/providers/ContainerProvider.tsx`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import { Container } from 'inversify';
|
||||
import { createContainer } from '../container';
|
||||
|
||||
const ContainerContext = createContext<Container | null>(null);
|
||||
|
||||
export function ContainerProvider({ children }: { children: ReactNode }) {
|
||||
const container = useMemo(() => createContainer(), []);
|
||||
|
||||
return (
|
||||
<ContainerContext.Provider value={container}>
|
||||
{children}
|
||||
</ContainerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useContainer(): Container {
|
||||
const container = useContext(ContainerContext);
|
||||
if (!container) throw new Error('Missing ContainerProvider');
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Injection Hook
|
||||
|
||||
**File**: `apps/website/lib/di/hooks/useInject.ts`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { ContainerContext } from '../providers/ContainerProvider';
|
||||
|
||||
export function useInject<T>(token: symbol): T {
|
||||
const container = useContext(ContainerContext);
|
||||
if (!container) throw new Error('Missing ContainerProvider');
|
||||
|
||||
return useMemo(() => container.get<T>(token), [container, token]);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Module Examples
|
||||
|
||||
**File**: `apps/website/lib/di/modules/league.module.ts`
|
||||
|
||||
```typescript
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { LeagueService } from '../../services/leagues/LeagueService';
|
||||
import {
|
||||
LEAGUE_SERVICE_TOKEN,
|
||||
LEAGUE_API_CLIENT_TOKEN,
|
||||
DRIVER_API_CLIENT_TOKEN,
|
||||
SPONSOR_API_CLIENT_TOKEN,
|
||||
RACE_API_CLIENT_TOKEN
|
||||
} from '../tokens';
|
||||
|
||||
export const LeagueModule = new ContainerModule((bind) => {
|
||||
bind<LeagueService>(LEAGUE_SERVICE_TOKEN)
|
||||
.toDynamicValue((context) => {
|
||||
const leagueApiClient = context.container.get(LEAGUE_API_CLIENT_TOKEN);
|
||||
const driverApiClient = context.container.get(DRIVER_API_CLIENT_TOKEN);
|
||||
const sponsorApiClient = context.container.get(SPONSOR_API_CLIENT_TOKEN);
|
||||
const raceApiClient = context.container.get(RACE_API_CLIENT_TOKEN);
|
||||
|
||||
return new LeagueService(
|
||||
leagueApiClient,
|
||||
driverApiClient,
|
||||
sponsorApiClient,
|
||||
raceApiClient
|
||||
);
|
||||
})
|
||||
.inSingletonScope();
|
||||
});
|
||||
```
|
||||
|
||||
**File**: `apps/website/lib/di/modules/api.module.ts`
|
||||
|
||||
```typescript
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||
// ... other API clients
|
||||
import {
|
||||
LEAGUE_API_CLIENT_TOKEN,
|
||||
DRIVER_API_CLIENT_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
ERROR_REPORTER_TOKEN,
|
||||
CONFIG_TOKEN
|
||||
} from '../tokens';
|
||||
|
||||
export const ApiModule = new ContainerModule((bind) => {
|
||||
const createApiClient = (ClientClass: any, context: any) => {
|
||||
const baseUrl = context.container.get(CONFIG_TOKEN);
|
||||
const errorReporter = context.container.get(ERROR_REPORTER_TOKEN);
|
||||
const logger = context.container.get(LOGGER_TOKEN);
|
||||
return new ClientClass(baseUrl, errorReporter, logger);
|
||||
};
|
||||
|
||||
bind(LEAGUE_API_CLIENT_TOKEN)
|
||||
.toDynamicValue(ctx => createApiClient(LeaguesApiClient, ctx))
|
||||
.inSingletonScope();
|
||||
|
||||
bind(DRIVER_API_CLIENT_TOKEN)
|
||||
.toDynamicValue(ctx => createApiClient(DriversApiClient, ctx))
|
||||
.inSingletonScope();
|
||||
|
||||
// ... other API clients
|
||||
});
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### In React-Query Hooks (Your Current Pattern)
|
||||
|
||||
**Before**:
|
||||
```typescript
|
||||
// apps/website/hooks/useLeagueService.ts
|
||||
export function useAllLeagues() {
|
||||
const { leagueService } = useServices(); // ❌ Manual service retrieval
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['allLeagues'],
|
||||
queryFn: () => leagueService.getAllLeagues(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**After**:
|
||||
```typescript
|
||||
// apps/website/hooks/useLeagueService.ts
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
export function useAllLeagues() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN); // ✅ Clean DI
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['allLeagues'],
|
||||
queryFn: () => leagueService.getAllLeagues(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### In React-Query Mutations
|
||||
|
||||
```typescript
|
||||
export function useCreateLeague() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateLeagueInputDTO) => leagueService.createLeague(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### In Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
export function CreateLeagueForm() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
// Use leagueService directly
|
||||
}
|
||||
```
|
||||
|
||||
### In Server Components
|
||||
|
||||
```typescript
|
||||
import { createContainer } from '@/lib/di/container';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
export default async function LeaguePage({ params }) {
|
||||
const container = createContainer();
|
||||
const leagueService = container.get(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
const league = await leagueService.getLeague(params.id);
|
||||
return <ClientComponent league={league} />;
|
||||
}
|
||||
```
|
||||
|
||||
### In Tests
|
||||
|
||||
```typescript
|
||||
import { createTestContainer } from '@/lib/di/container';
|
||||
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
|
||||
|
||||
test('useAllLeagues works with DI', () => {
|
||||
const mockLeagueService = {
|
||||
getAllLeagues: jest.fn().mockResolvedValue([{ id: '1', name: 'Test League' }])
|
||||
};
|
||||
|
||||
const overrides = new Map([
|
||||
[LEAGUE_SERVICE_TOKEN, mockLeagueService]
|
||||
]);
|
||||
|
||||
const container = createTestContainer(overrides);
|
||||
|
||||
const { result } = renderHook(() => useAllLeagues(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ContainerProvider container={container}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</ContainerProvider>
|
||||
)
|
||||
});
|
||||
|
||||
// Test works exactly the same
|
||||
});
|
||||
```
|
||||
|
||||
### Complete React-Query Hook Migration Example
|
||||
|
||||
```typescript
|
||||
// apps/website/hooks/useLeagueService.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||
|
||||
export function useAllLeagues() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['allLeagues'],
|
||||
queryFn: () => leagueService.getAllLeagues(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeagueStandings(leagueId: string, currentUserId: string) {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['leagueStandings', leagueId, currentUserId],
|
||||
queryFn: () => leagueService.getLeagueStandings(leagueId, currentUserId),
|
||||
enabled: !!leagueId && !!currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLeague() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateLeagueInputDTO) => leagueService.createLeague(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveLeagueMember() {
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ leagueId, performerDriverId, targetDriverId }: {
|
||||
leagueId: string;
|
||||
performerDriverId: string;
|
||||
targetDriverId: string;
|
||||
}) => leagueService.removeMember(leagueId, performerDriverId, targetDriverId),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['leagueMemberships', variables.leagueId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['leagueStandings', variables.leagueId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Services in One Hook
|
||||
|
||||
```typescript
|
||||
import { useInjectMany } from '@/lib/di/hooks/useInjectMany';
|
||||
import { LEAGUE_SERVICE_TOKEN, RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
export function useLeagueAndRaceData(leagueId: string) {
|
||||
const [leagueService, raceService] = useInjectMany([
|
||||
LEAGUE_SERVICE_TOKEN,
|
||||
RACE_SERVICE_TOKEN
|
||||
]);
|
||||
|
||||
const leagueQuery = useQuery({
|
||||
queryKey: ['league', leagueId],
|
||||
queryFn: () => leagueService.getLeague(leagueId),
|
||||
});
|
||||
|
||||
const racesQuery = useQuery({
|
||||
queryKey: ['races', leagueId],
|
||||
queryFn: () => raceService.getRacesByLeague(leagueId),
|
||||
});
|
||||
|
||||
return { leagueQuery, racesQuery };
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Setup (Week 1)
|
||||
1. Install dependencies
|
||||
2. Configure TypeScript
|
||||
3. Create core infrastructure
|
||||
4. Create API module
|
||||
|
||||
### Phase 2: Domain Modules (Week 2-3)
|
||||
1. Create module for each domain (2-3 per day)
|
||||
2. Register all services
|
||||
3. Test each module
|
||||
|
||||
### Phase 3: React-Query Hooks Migration (Week 4)
|
||||
1. Update all hooks to use `useInject()` instead of `useServices()`
|
||||
2. Migrate one domain at a time:
|
||||
- `useLeagueService.ts` → `useInject(LEAGUE_SERVICE_TOKEN)`
|
||||
- `useRaceService.ts` → `useInject(RACE_SERVICE_TOKEN)`
|
||||
- `useDriverService.ts` → `useInject(DRIVER_SERVICE_TOKEN)`
|
||||
- etc.
|
||||
3. Test each hook after migration
|
||||
|
||||
### Phase 4: Integration & Cleanup (Week 5)
|
||||
1. Update root layout with ContainerProvider
|
||||
2. Remove old ServiceProvider
|
||||
3. Remove ServiceFactory
|
||||
4. Final verification
|
||||
|
||||
### React-Query Hook Migration Pattern
|
||||
|
||||
Each hook file gets a simple 2-line change:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
const { leagueService } = useServices();
|
||||
|
||||
// After
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
```
|
||||
|
||||
The rest of the hook (queries, mutations, etc.) stays exactly the same.
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Testability**: Easy mocking via container overrides
|
||||
✅ **Maintainability**: Clear dependency graphs
|
||||
✅ **Type Safety**: Compile-time validation
|
||||
✅ **Consistency**: Same patterns as API
|
||||
✅ **Performance**: Singleton scope by default
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `apps/website/lib/di/index.ts`
|
||||
2. `apps/website/lib/di/container.ts`
|
||||
3. `apps/website/lib/di/tokens.ts`
|
||||
4. `apps/website/lib/di/providers/ContainerProvider.tsx`
|
||||
5. `apps/website/lib/di/hooks/useInject.ts`
|
||||
6. `apps/website/lib/di/modules/core.module.ts`
|
||||
7. `apps/website/lib/di/modules/api.module.ts`
|
||||
8. `apps/website/lib/di/modules/league.module.ts`
|
||||
9. `apps/website/lib/di/modules/driver.module.ts`
|
||||
10. `apps/website/lib/di/modules/team.module.ts`
|
||||
11. `apps/website/lib/di/modules/race.module.ts`
|
||||
12. `apps/website/lib/di/modules/media.module.ts`
|
||||
13. `apps/website/lib/di/modules/payment.module.ts`
|
||||
14. `apps/website/lib/di/modules/analytics.module.ts`
|
||||
15. `apps/website/lib/di/modules/auth.module.ts`
|
||||
16. `apps/website/lib/di/modules/dashboard.module.ts`
|
||||
17. `apps/website/lib/di/modules/policy.module.ts`
|
||||
18. `apps/website/lib/di/modules/protest.module.ts`
|
||||
19. `apps/website/lib/di/modules/penalty.module.ts`
|
||||
20. `apps/website/lib/di/modules/onboarding.module.ts`
|
||||
21. `apps/website/lib/di/modules/landing.module.ts`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Approve this plan**
|
||||
2. **Start Phase 1**: Install dependencies and create core infrastructure
|
||||
3. **Proceed module by module**: No backward compatibility needed - clean migration
|
||||
|
||||
**Total effort**: 4-5 weeks for clean implementation
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,462 +0,0 @@
|
||||
# Media Architecture: Complete Analysis & Corrected Solution
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Your media architecture plans contain **fundamental flaws** based on misunderstandings of the current codebase. This document provides a complete analysis and the correct, streamlined solution.
|
||||
|
||||
**Key Finding:** Your plans solve non-existent problems while ignoring real ones, and over-engineer simple solutions.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: What's Wrong with Your Plans
|
||||
|
||||
### 1.1 Critical Flaws
|
||||
|
||||
#### **Flaw #1: Solving Non-Existent Problems**
|
||||
|
||||
**Your Claim:** "Database stores logoUrl in teams table"
|
||||
```typescript
|
||||
// Your plan claims this exists:
|
||||
teams table: { id: '123', logoUrl: '/images/logos/team-123.jpg' }
|
||||
```
|
||||
|
||||
**Reality:**
|
||||
```typescript
|
||||
// adapters/racing/persistence/typeorm/entities/TeamOrmEntity.ts
|
||||
@Entity({ name: 'racing_teams' })
|
||||
export class TeamOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
tag!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ type: 'uuid', array: true })
|
||||
leagues!: string[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRecruiting!: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
```
|
||||
|
||||
**❌ NO logoUrl column exists!** Your plan is solving a problem that doesn't exist.
|
||||
|
||||
#### **Flaw #2: Duplicating Existing Work**
|
||||
|
||||
**Your Claim:** "Need to implement SVG generation"
|
||||
**Reality:** Already exists in `MediaController`
|
||||
|
||||
```typescript
|
||||
// apps/api/src/domain/media/MediaController.ts
|
||||
@Get('avatar/:driverId')
|
||||
async getDriverAvatar(
|
||||
@Param('driverId') driverId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = this.generateDriverAvatarSVG(driverId); // ✅ Already implemented
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
private generateDriverAvatarSVG(driverId: string): string {
|
||||
faker.seed(this.hashCode(driverId)); // ✅ Already using Faker
|
||||
// ... 50+ lines of SVG generation
|
||||
}
|
||||
```
|
||||
|
||||
**Your Claim:** "Need Next.js rewrites"
|
||||
**Reality:** Already configured
|
||||
|
||||
```javascript
|
||||
// apps/website/next.config.mjs
|
||||
async rewrites() {
|
||||
const baseUrl = 'http://api:3000';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${baseUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### **Flaw #3: Ignoring Real Problems**
|
||||
|
||||
**Real Problem 1: Controller Business Logic**
|
||||
```typescript
|
||||
// apps/api/src/domain/media/MediaController.ts
|
||||
private generateDriverAvatarSVG(driverId: string): string {
|
||||
faker.seed(this.hashCode(driverId));
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const initials = ((firstName?.[0] || 'D') + (lastName?.[0] || 'R')).toUpperCase();
|
||||
const primaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const secondaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const patterns = ['gradient', 'stripes', 'circles', 'diamond'];
|
||||
const pattern = faker.helpers.arrayElement(patterns);
|
||||
// ... 40 more lines
|
||||
}
|
||||
```
|
||||
**Your Plans:** Don't address this
|
||||
|
||||
**Real Problem 2: Inconsistent Seeds**
|
||||
```typescript
|
||||
// adapters/bootstrap/SeedRacingData.ts
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = this.getDriverAvatarUrl(driver.id); // ❌ Static files
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `/api/media/teams/${team.id}/logo`; // ✅ API endpoints
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
}
|
||||
```
|
||||
**Your Plans:** Claim seeds use API (partially true, but inconsistent)
|
||||
|
||||
**Real Problem 3: Mixed Repository**
|
||||
```typescript
|
||||
// adapters/racing/persistence/media/InMemoryMediaRepository.ts
|
||||
// Stores static file paths AND API endpoints
|
||||
// Purpose unclear
|
||||
```
|
||||
**Your Plans:** Don't address this
|
||||
|
||||
#### **Flaw #4: Over-Engineering**
|
||||
|
||||
**Simple Problem:** Generate SVG for avatar
|
||||
**Your Solution:** 4+ layers
|
||||
```
|
||||
Controller → Service → Use Case → Generator → Repository → Presenter
|
||||
```
|
||||
|
||||
**Correct Solution:** 2 layers
|
||||
```
|
||||
Controller → Domain Service
|
||||
```
|
||||
|
||||
#### **Flaw #5: Violating Your Own Rules**
|
||||
|
||||
**Your Plans Claim:** "Domain should not store URLs"
|
||||
**Your Proposed Domain:**
|
||||
```typescript
|
||||
// core/media/domain/entities/MediaAsset.ts
|
||||
export class MediaAsset {
|
||||
constructor(
|
||||
public readonly id: MediaId,
|
||||
public readonly type: MediaType,
|
||||
public readonly url: MediaUrl, // ❌ Still storing URLs!
|
||||
public readonly generationParams: MediaGenerationParams
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: The Real Problems
|
||||
|
||||
### Problem 1: Controller Business Logic
|
||||
**Location:** `apps/api/src/domain/media/MediaController.ts` (lines 214-330)
|
||||
**Issue:** 100+ lines of SVG generation in controller
|
||||
**Impact:** Violates clean architecture, hard to test
|
||||
|
||||
### Problem 2: Inconsistent Seed Approach
|
||||
**Location:** `adapters/bootstrap/SeedRacingData.ts`
|
||||
**Issue:** Driver avatars use static files, team logos use API
|
||||
**Impact:** Inconsistent behavior, static files still needed
|
||||
|
||||
### Problem 3: Mixed Repository Responsibilities
|
||||
**Location:** `adapters/racing/persistence/media/InMemoryMediaRepository.ts`
|
||||
**Issue:** Stores both static URLs and API endpoints
|
||||
**Impact:** Unclear purpose, violates single responsibility
|
||||
|
||||
### Problem 4: No Clean Architecture Separation
|
||||
**Issue:** No proper domain layer for media
|
||||
**Impact:** Infrastructure mixed with application logic
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Correct Solution
|
||||
|
||||
### 3.1 Architecture Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Presentation (apps/website) │
|
||||
│ - MediaService returns API endpoints │
|
||||
│ - Components use <img src="/api/media/..."> │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HTTP Layer (apps/api) │
|
||||
│ - MediaController (HTTP only) │
|
||||
│ - Routes: /api/media/avatar/:id, /api/media/teams/:id/logo│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Domain Layer (core/media/domain) │
|
||||
│ - MediaGenerationService (business logic) │
|
||||
│ - MediaGenerator (port) │
|
||||
│ - MediaRepository (port) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Infrastructure (adapters/media) │
|
||||
│ - FakerMediaGenerator (seeds) │
|
||||
│ - InMemoryMediaRepository (seeds) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Implementation Steps
|
||||
|
||||
#### **Step 1: Create Domain Service**
|
||||
```typescript
|
||||
// core/media/domain/services/MediaGenerationService.ts
|
||||
export class MediaGenerationService {
|
||||
generateDriverAvatar(driverId: string): string {
|
||||
faker.seed(this.hashCode(driverId));
|
||||
// ... SVG generation logic
|
||||
}
|
||||
|
||||
generateTeamLogo(teamId: string): string {
|
||||
faker.seed(this.hashCode(teamId));
|
||||
// ... SVG generation logic
|
||||
}
|
||||
|
||||
private hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **Step 2: Clean Controller**
|
||||
```typescript
|
||||
// apps/api/src/domain/media/MediaController.ts
|
||||
@Get('avatar/:driverId')
|
||||
async getDriverAvatar(
|
||||
@Param('driverId') driverId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = this.mediaGenerationService.generateDriverAvatar(driverId);
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
}
|
||||
|
||||
// ❌ REMOVE duplicate endpoints
|
||||
// ❌ REMOVE generateDriverAvatarSVG() method
|
||||
// ❌ REMOVE generateTeamLogoSVG() method
|
||||
// ❌ REMOVE hashCode() method
|
||||
```
|
||||
|
||||
#### **Step 3: Fix Seeds**
|
||||
```typescript
|
||||
// adapters/bootstrap/SeedRacingData.ts
|
||||
private async seedMediaAssets(seed: any): Promise<void> {
|
||||
// ✅ ALL media uses API endpoints
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = `/api/media/avatar/${driver.id}`;
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setDriverAvatar) {
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `/api/media/teams/${team.id}/logo`;
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTeamLogo) {
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Remove static file logic
|
||||
// ✅ Remove getDriverAvatarUrl() method
|
||||
}
|
||||
```
|
||||
|
||||
#### **Step 4: Clean Repository**
|
||||
```typescript
|
||||
// adapters/racing/persistence/media/InMemoryMediaRepository.ts
|
||||
export class InMemoryMediaRepository implements IMediaRepository {
|
||||
private driverAvatars = new Map<string, string>();
|
||||
private teamLogos = new Map<string, string>();
|
||||
|
||||
setDriverAvatar(driverId: string, apiUrl: string): void {
|
||||
this.driverAvatars.set(driverId, apiUrl);
|
||||
}
|
||||
|
||||
setTeamLogo(teamId: string, apiUrl: string): void {
|
||||
this.teamLogos.set(teamId, apiUrl);
|
||||
}
|
||||
|
||||
// ✅ Remove unused methods
|
||||
// ❌ remove getTrackImage, getCategoryIcon, getSponsorLogo
|
||||
}
|
||||
```
|
||||
|
||||
#### **Step 5: Remove Static Files**
|
||||
```bash
|
||||
rm -f apps/website/public/images/avatars/male-default-avatar.jpg
|
||||
rm -f apps/website/public/images/avatars/female-default-avatar.jpeg
|
||||
rm -f apps/website/public/images/avatars/neutral-default-avatar.jpeg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: File Changes Summary
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. **apps/api/src/domain/media/MediaController.ts**
|
||||
- Remove SVG generation logic (lines 214-330)
|
||||
- Remove duplicate endpoints
|
||||
- Call domain service
|
||||
|
||||
2. **adapters/bootstrap/SeedRacingData.ts**
|
||||
- Use API endpoints for ALL media
|
||||
- Remove static file logic
|
||||
- Remove getDriverAvatarUrl()
|
||||
|
||||
3. **adapters/racing/persistence/media/InMemoryMediaRepository.ts**
|
||||
- Simplify to store only API endpoints
|
||||
- Remove unused methods
|
||||
|
||||
4. **core/media/domain/services/MediaGenerationService.ts** (NEW)
|
||||
- Contains all SVG generation logic
|
||||
- Uses Faker for seeds
|
||||
|
||||
### Files to Delete
|
||||
|
||||
1. **apps/website/public/images/avatars/** (all static files)
|
||||
|
||||
### Files to Keep (Already Correct)
|
||||
|
||||
1. **apps/website/lib/services/media/MediaService.ts** ✅
|
||||
2. **apps/website/next.config.mjs** ✅
|
||||
3. **apps/api/src/domain/media/MediaController.ts** (cleaned version)
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Implementation Timeline
|
||||
|
||||
### Day 1: Controller Cleanup
|
||||
- Create MediaGenerationService
|
||||
- Move SVG logic from controller
|
||||
- Remove duplicate endpoints
|
||||
|
||||
### Day 2: Seed Fixes
|
||||
- Update SeedRacingData to use API endpoints
|
||||
- Remove static file logic
|
||||
- Clean up InMemoryMediaRepository
|
||||
|
||||
### Day 3: Testing & Cleanup
|
||||
- Remove static files
|
||||
- TypeScript compilation
|
||||
- Integration tests
|
||||
|
||||
**Total: 3 days** (vs 10+ days in your plans)
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Success Criteria
|
||||
|
||||
After implementation:
|
||||
|
||||
1. ✅ **No static files** in `apps/website/public/images/avatars/`
|
||||
2. ✅ **No SVG generation** in `MediaController`
|
||||
3. ✅ **Consistent seed approach** - all API endpoints
|
||||
4. ✅ **Clean repository** - single responsibility
|
||||
5. ✅ **All TypeScript errors resolved**
|
||||
6. ✅ **Website displays all media correctly**
|
||||
7. ✅ **Same ID always produces same SVG** (via Faker seeding)
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Comparison Table
|
||||
|
||||
| Aspect | Your Plans | Correct Solution |
|
||||
|--------|------------|------------------|
|
||||
| **Database Changes** | Remove logoUrl (❌ don't exist) | No changes needed |
|
||||
| **Next.js Config** | Add rewrites (❌ already exists) | Keep existing |
|
||||
| **API Endpoints** | Add 8 endpoints (❌ duplicates) | Keep 4 existing |
|
||||
| **SVG Generation** | Use cases + generators (❌ over-engineered) | Domain service |
|
||||
| **Seeds** | Hybrid approach (❌ confusing) | All API endpoints |
|
||||
| **Architecture** | Complex layers (❌ over-engineered) | Clean & simple |
|
||||
| **Static Files** | Keep some (❌ inconsistent) | Remove all |
|
||||
| **Implementation Time** | 10+ days | 3 days |
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Why Your Plans Fail
|
||||
|
||||
1. **Lack of Analysis:** Written without understanding current state
|
||||
2. **Over-Engineering:** Adding layers where simple solutions suffice
|
||||
3. **Inconsistent:** Claims to solve problems that don't exist
|
||||
4. **Violates Own Rules:** Criticizes URL storage, then proposes it
|
||||
5. **Duplicates Work:** Implements what already exists
|
||||
|
||||
---
|
||||
|
||||
## Part 9: The Bottom Line
|
||||
|
||||
### Your Plans Are:
|
||||
- ❌ Based on incorrect assumptions
|
||||
- ❌ Solving non-existent problems
|
||||
- ❌ Ignoring real problems
|
||||
- ❌ Over-engineering simple solutions
|
||||
- ❌ Duplicating existing work
|
||||
|
||||
### Your Plans Should Be:
|
||||
- ✅ Based on actual current state
|
||||
- ✅ Solving real problems only
|
||||
- ✅ Simple and direct
|
||||
- ✅ Clean architecture without complexity
|
||||
- ✅ Implementable in 3 days
|
||||
|
||||
---
|
||||
|
||||
## Part 10: Recommendation
|
||||
|
||||
**DO NOT implement your current plans.**
|
||||
|
||||
Instead, implement this streamlined solution that:
|
||||
1. Fixes actual problems (controller logic, inconsistent seeds, mixed repository)
|
||||
2. Ignores imaginary problems (database schema, rewrites, SVG implementation)
|
||||
3. Uses simple, direct architecture
|
||||
4. Can be completed in 3 days
|
||||
|
||||
**Your plans have good intentions but are fundamentally flawed.** This document provides the correct path forward.
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
- `plans/MEDIA_ARCHITECTURE_COMPLETE_ANALYSIS.md` (this file)
|
||||
- `plans/MEDIA_ARCHITECTURE_ANALYSIS.md` (detailed analysis)
|
||||
- `plans/MEDIA_STREAMLINED_SOLUTION.md` (corrected approach)
|
||||
- `plans/CHALLENGE_TO_YOUR_PLANS.md` (point-by-point challenge)
|
||||
|
||||
**Recommendation:** Keep only this file and delete the others.
|
||||
@@ -1,526 +0,0 @@
|
||||
# Unified Logging Plan - Professional & Developer Friendly
|
||||
|
||||
## Problem Summary
|
||||
|
||||
**Current Issues:**
|
||||
- Website logs are overly aggressive and verbose
|
||||
- Network errors show full stack traces (looks like syntax errors)
|
||||
- Multiple error formats for same issue
|
||||
- Not machine-readable
|
||||
- Different patterns than apps/api
|
||||
|
||||
**Goal:** Create unified, professional logging that's both machine-readable AND beautiful for developers.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
### 1. Unified Logger Interface (No Core Imports)
|
||||
```typescript
|
||||
// apps/website/lib/interfaces/Logger.ts
|
||||
export interface Logger {
|
||||
debug(message: string, context?: unknown): void;
|
||||
info(message: string, context?: unknown): void;
|
||||
warn(message: string, context?: unknown): void;
|
||||
error(message: string, error?: Error, context?: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. How Website Logging Aligns with apps/api
|
||||
|
||||
**apps/api ConsoleLogger (Simple & Clean):**
|
||||
```typescript
|
||||
// adapters/logging/ConsoleLogger.ts
|
||||
formatMessage(level: string, message: string, context?: unknown): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const contextStr = context ? ` | ${JSON.stringify(context)}` : '';
|
||||
return `[${timestamp}] ${level.toUpperCase()}: ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
// Output: [2026-01-06T12:00:00.000Z] WARN: Network error, retrying... | {"endpoint":"/auth/session"}
|
||||
```
|
||||
|
||||
**apps/website ConsoleLogger (Enhanced & Developer-Friendly):**
|
||||
```typescript
|
||||
// apps/website/lib/infrastructure/logging/ConsoleLogger.ts
|
||||
formatOutput(level: string, source: string, message: string, context?: unknown, error?: Error): void {
|
||||
const color = this.COLORS[level];
|
||||
const emoji = this.EMOJIS[level];
|
||||
const prefix = this.PREFIXES[level];
|
||||
|
||||
// Same core format as apps/api, but enhanced with colors/emojis
|
||||
console.groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
||||
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString());
|
||||
console.log(`%cContext:`, 'color: #666; font-weight: bold;');
|
||||
console.dir(context, { depth: 3, colors: true });
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Output: ⚠️ [API] NETWORK WARN: Network error, retrying...
|
||||
// ├─ Timestamp: 2026-01-06T12:00:00.000Z
|
||||
// ├─ Context: { endpoint: "/auth/session", ... }
|
||||
```
|
||||
|
||||
**Alignment:**
|
||||
- ✅ Same timestamp format (ISO 8601)
|
||||
- ✅ Same log levels (debug, info, warn, error)
|
||||
- ✅ Same context structure
|
||||
- ✅ Same message patterns
|
||||
- ✅ Website adds colors/emojis for better UX
|
||||
|
||||
### 3. Unified API Client Logging Strategy
|
||||
**Both apps/api and apps/website use the same patterns:**
|
||||
|
||||
```typescript
|
||||
// In BaseApiClient (shared logic):
|
||||
private handleError(error: ApiError): void {
|
||||
const severity = error.getSeverity();
|
||||
const message = error.getDeveloperMessage();
|
||||
|
||||
// Same logic for both:
|
||||
if (error.context.isRetryable && error.context.retryCount > 0) {
|
||||
// Network errors during retry = warn (not error)
|
||||
this.logger.warn(message, error.context);
|
||||
} else if (severity === 'error') {
|
||||
// Final failure = error
|
||||
this.logger.error(message, error, error.context);
|
||||
} else {
|
||||
// Other errors = warn
|
||||
this.logger.warn(message, error.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Unified Error Classification
|
||||
**Both environments use the same severity levels:**
|
||||
- **error**: Critical failures (server down, auth failures, data corruption)
|
||||
- **warn**: Expected errors (network timeouts, CORS, validation failures)
|
||||
- **info**: Normal operations (successful retries, connectivity恢复)
|
||||
- **debug**: Detailed info (development only)
|
||||
|
||||
### 5. Example: Same Error, Different Output
|
||||
|
||||
**Scenario: Server down, retrying connection**
|
||||
|
||||
**apps/api output:**
|
||||
```
|
||||
[2026-01-06T12:00:00.000Z] WARN: [NETWORK_ERROR] GET /auth/session retry:1 | {"endpoint":"/auth/session","method":"GET","retryCount":1}
|
||||
```
|
||||
|
||||
**apps/website output:**
|
||||
```
|
||||
⚠️ [API] NETWORK WARN: GET /auth/session - retry:1
|
||||
├─ Timestamp: 2026-01-06T12:00:00.000Z
|
||||
├─ Endpoint: /auth/session
|
||||
├─ Method: GET
|
||||
├─ Retry Count: 1
|
||||
└─ Hint: Check if API server is running and CORS is configured
|
||||
```
|
||||
|
||||
**Key Alignment Points:**
|
||||
1. **Same log level**: `warn` (not `error`)
|
||||
2. **Same context**: `{endpoint, method, retryCount}`
|
||||
3. **Same message pattern**: Includes retry count
|
||||
4. **Same timestamp format**: ISO 8601
|
||||
5. **Website just adds**: Colors, emojis, and developer hints
|
||||
|
||||
This creates a **unified logging ecosystem** where:
|
||||
- Logs can be parsed the same way
|
||||
- Severity levels mean the same thing
|
||||
- Context structures are identical
|
||||
- Website enhances for developer experience
|
||||
- apps/api keeps it simple for server logs
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### File 1: Logger Interface
|
||||
**Path:** `apps/website/lib/interfaces/Logger.ts`
|
||||
|
||||
```typescript
|
||||
export interface Logger {
|
||||
debug(message: string, context?: unknown): void;
|
||||
info(message: string, context?: unknown): void;
|
||||
warn(message: string, context?: unknown): void;
|
||||
error(message: string, error?: Error, context?: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### File 2: ErrorReporter Interface
|
||||
**Path:** `apps/website/lib/interfaces/ErrorReporter.ts`
|
||||
|
||||
```typescript
|
||||
export interface ErrorReporter {
|
||||
report(error: Error, context?: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### File 3: Enhanced ConsoleLogger (Human-Readable Only)
|
||||
**Path:** `apps/website/lib/infrastructure/logging/ConsoleLogger.ts`
|
||||
|
||||
```typescript
|
||||
import { Logger } from '../../interfaces/Logger';
|
||||
|
||||
export class ConsoleLogger implements Logger {
|
||||
private readonly COLORS = { debug: '#888888', info: '#00aaff', warn: '#ffaa00', error: '#ff4444' };
|
||||
private readonly EMOJIS = { debug: '🐛', info: 'ℹ️', warn: '⚠️', error: '❌' };
|
||||
private readonly PREFIXES = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR' };
|
||||
|
||||
private shouldLog(level: string): boolean {
|
||||
if (process.env.NODE_ENV === 'test') return level === 'error';
|
||||
if (process.env.NODE_ENV === 'production') return level !== 'debug';
|
||||
return true;
|
||||
}
|
||||
|
||||
private formatOutput(level: string, source: string, message: string, context?: unknown, error?: Error): void {
|
||||
const color = this.COLORS[level];
|
||||
const emoji = this.EMOJIS[level];
|
||||
const prefix = this.PREFIXES[level];
|
||||
|
||||
console.groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
||||
|
||||
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString());
|
||||
console.log(`%cSource:`, 'color: #666; font-weight: bold;', source);
|
||||
|
||||
if (context) {
|
||||
console.log(`%cContext:`, 'color: #666; font-weight: bold;');
|
||||
console.dir(context, { depth: 3, colors: true });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log(`%cError Details:`, 'color: #666; font-weight: bold;');
|
||||
console.log(`%cType:`, 'color: #ff4444; font-weight: bold;', error.name);
|
||||
console.log(`%cMessage:`, 'color: #ff4444; font-weight: bold;', error.message);
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && error.stack) {
|
||||
console.log(`%cStack Trace:`, 'color: #666; font-weight: bold;');
|
||||
console.log(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
debug(message: string, context?: unknown): void {
|
||||
if (!this.shouldLog('debug')) return;
|
||||
this.formatOutput('debug', 'website', message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: unknown): void {
|
||||
if (!this.shouldLog('info')) return;
|
||||
this.formatOutput('info', 'website', message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: unknown): void {
|
||||
if (!this.shouldLog('warn')) return;
|
||||
this.formatOutput('warn', 'website', message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: unknown): void {
|
||||
if (!this.shouldLog('error')) return;
|
||||
this.formatOutput('error', 'website', message, context, error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File 4: ConsoleErrorReporter
|
||||
**Path:** `apps/website/lib/infrastructure/ConsoleErrorReporter.ts`
|
||||
|
||||
```typescript
|
||||
import { ErrorReporter } from '../../interfaces/ErrorReporter';
|
||||
|
||||
export class ConsoleErrorReporter implements ErrorReporter {
|
||||
report(error: Error, context?: unknown): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.error(`[${timestamp}] Error reported:`, error.message, { error, context });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File 5: Updated BaseApiClient
|
||||
**Path:** `apps/website/lib/api/base/BaseApiClient.ts`
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
1. **Update createNetworkError:**
|
||||
```typescript
|
||||
private createNetworkError(error: Error, method: string, path: string, retryCount: number = 0): ApiError {
|
||||
// ... existing logic ...
|
||||
|
||||
return new ApiError(
|
||||
message,
|
||||
errorType,
|
||||
{
|
||||
endpoint: path,
|
||||
method,
|
||||
timestamp: new Date().toISOString(),
|
||||
retryCount,
|
||||
troubleshooting: this.getTroubleshootingContext(error, path),
|
||||
isRetryable: retryableTypes.includes(errorType),
|
||||
isConnectivity: errorType === 'NETWORK_ERROR' || errorType === 'TIMEOUT_ERROR',
|
||||
developerHint: this.getDeveloperHint(error, path, method),
|
||||
},
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
private getDeveloperHint(error: Error, path: string, method: string): string {
|
||||
if (error.message.includes('fetch failed') || error.message.includes('Failed to fetch')) {
|
||||
return 'Check if API server is running and CORS is configured correctly';
|
||||
}
|
||||
if (error.message.includes('timeout')) {
|
||||
return 'Request timed out - consider increasing timeout or checking network';
|
||||
}
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
return 'Connection refused - verify API server address and port';
|
||||
}
|
||||
return 'Review network connection and API endpoint configuration';
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update handleError:**
|
||||
```typescript
|
||||
private handleError(error: ApiError): void {
|
||||
const severity = error.getSeverity();
|
||||
const message = error.getDeveloperMessage();
|
||||
|
||||
const enhancedContext = {
|
||||
...error.context,
|
||||
severity,
|
||||
isRetryable: error.isRetryable(),
|
||||
isConnectivity: error.isConnectivityIssue(),
|
||||
};
|
||||
|
||||
if (severity === 'error') {
|
||||
this.logger.error(message, error, enhancedContext);
|
||||
} else if (severity === 'warn') {
|
||||
this.logger.warn(message, enhancedContext);
|
||||
} else {
|
||||
this.logger.info(message, enhancedContext);
|
||||
}
|
||||
|
||||
this.errorReporter.report(error, enhancedContext);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update request method logging:**
|
||||
```typescript
|
||||
// In catch block:
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
// Reduce verbosity - only log final failure
|
||||
if (process.env.NODE_ENV === 'development' && requestId) {
|
||||
try {
|
||||
const apiLogger = getGlobalApiLogger();
|
||||
// This will use warn level for retryable errors
|
||||
apiLogger.logError(requestId, error, responseTime);
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// ... rest of error handling
|
||||
}
|
||||
```
|
||||
|
||||
### File 6: Updated ApiRequestLogger
|
||||
**Path:** `apps/website/lib/infrastructure/ApiRequestLogger.ts`
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
```typescript
|
||||
// Update logError to use warn for network errors:
|
||||
logError(id: string, error: Error, duration: number): void {
|
||||
// ... existing setup ...
|
||||
|
||||
const isNetworkError = error.message.includes('fetch') ||
|
||||
error.message.includes('Failed to fetch') ||
|
||||
error.message.includes('NetworkError');
|
||||
|
||||
if (this.options.logToConsole) {
|
||||
const emoji = isNetworkError ? '⚠️' : '❌';
|
||||
const prefix = isNetworkError ? 'NETWORK WARN' : 'ERROR';
|
||||
const color = isNetworkError ? '#ffaa00' : '#ff4444';
|
||||
|
||||
console.groupCollapsed(
|
||||
`%c${emoji} [API] ${prefix}: ${log.method} ${log.url}`,
|
||||
`color: ${color}; font-weight: bold; font-size: 12px;`
|
||||
);
|
||||
console.log(`%cRequest ID:`, 'color: #666; font-weight: bold;', id);
|
||||
console.log(`%cDuration:`, 'color: #666; font-weight: bold;', `${duration.toFixed(2)}ms`);
|
||||
console.log(`%cError:`, 'color: #666; font-weight: bold;', error.message);
|
||||
console.log(`%cType:`, 'color: #666; font-weight: bold;', error.name);
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && error.stack) {
|
||||
console.log(`%cStack:`, 'color: #666; font-weight: bold;');
|
||||
console.log(error.stack);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Don't report network errors to external services
|
||||
if (!isNetworkError) {
|
||||
const globalHandler = getGlobalErrorHandler();
|
||||
globalHandler.report(error, {
|
||||
source: 'api_request',
|
||||
url: log.url,
|
||||
method: log.method,
|
||||
duration,
|
||||
requestId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File 7: Updated GlobalErrorHandler
|
||||
**Path:** `apps/website/lib/infrastructure/GlobalErrorHandler.ts`
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
```typescript
|
||||
// In handleWindowError:
|
||||
private handleWindowError = (event: ErrorEvent): void => {
|
||||
const error = event.error;
|
||||
|
||||
// Check if this is a network/CORS error (expected in some cases)
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
this.logger.warn('Network error detected', {
|
||||
type: 'network_error',
|
||||
message: error.message,
|
||||
url: event.filename
|
||||
});
|
||||
return; // Don't prevent default for network errors
|
||||
}
|
||||
|
||||
// ... existing logic for other errors ...
|
||||
};
|
||||
|
||||
// Update logErrorWithMaximumDetail:
|
||||
private logErrorWithMaximumDetail(error: Error | ApiError, context: Record<string, unknown>): void {
|
||||
if (!this.options.verboseLogging) return;
|
||||
|
||||
const isApiError = error instanceof ApiError;
|
||||
const isWarning = isApiError && error.getSeverity() === 'warn';
|
||||
|
||||
if (isWarning) {
|
||||
console.groupCollapsed(`%c⚠️ [WARNING] ${error.message}`, 'color: #ffaa00; font-weight: bold; font-size: 14px;');
|
||||
console.log('Context:', context);
|
||||
console.groupEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Full error details for actual errors
|
||||
console.groupCollapsed(`%c❌ [ERROR] ${error.name || 'Error'}: ${error.message}`, 'color: #ff4444; font-weight: bold; font-size: 16px;');
|
||||
|
||||
console.log('%cError Details:', 'color: #ff4444; font-weight: bold; font-size: 14px;');
|
||||
console.table({
|
||||
Name: error.name,
|
||||
Message: error.message,
|
||||
Type: isApiError ? error.type : 'N/A',
|
||||
Severity: isApiError ? error.getSeverity() : 'error',
|
||||
Retryable: isApiError ? error.isRetryable() : 'N/A',
|
||||
});
|
||||
|
||||
console.log('%cContext:', 'color: #666; font-weight: bold; font-size: 14px;');
|
||||
console.dir(context, { depth: 4, colors: true });
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && error.stack) {
|
||||
console.log('%cStack Trace:', 'color: #666; font-weight: bold; font-size: 14px;');
|
||||
console.log(error.stack);
|
||||
}
|
||||
|
||||
if (isApiError && error.context?.developerHint) {
|
||||
console.log('%c💡 Developer Hint:', 'color: #00aaff; font-weight: bold; font-size: 14px;');
|
||||
console.log(error.context.developerHint);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
this.logger.error(error.message, error, context);
|
||||
}
|
||||
```
|
||||
|
||||
## Expected Results
|
||||
|
||||
### Before (Current - Too Verbose)
|
||||
```
|
||||
[API-ERROR] [NETWORK_ERROR] Unable to connect to server. Possible CORS or network issue. GET /auth/session {
|
||||
error: Error [ApiError]: Unable to connect to server. Possible CORS or network issue.
|
||||
at AuthApiClient.createNetworkError (lib/api/base/BaseApiClient.ts:136:12)
|
||||
at executeRequest (lib/api/base/BaseApiClient.ts:314:31)
|
||||
...
|
||||
}
|
||||
[USER-NOTIFICATION] Unable to connect to the server. Please check your internet connection.
|
||||
[API ERROR] GET http://api:3000/auth/session
|
||||
Request ID: req_1767694969495_4
|
||||
Duration: 8.00ms
|
||||
Error: Unable to connect to server. Possible CORS or network issue.
|
||||
Type: ApiError
|
||||
Stack: ApiError: Unable to connect to server...
|
||||
```
|
||||
|
||||
### After (Unified - Clean & Beautiful)
|
||||
|
||||
Beautiful console output:
|
||||
```
|
||||
⚠️ [API] NETWORK WARN: GET /auth/session - retry:1
|
||||
├─ Request ID: req_123
|
||||
├─ Duration: 8.00ms
|
||||
├─ Error: fetch failed
|
||||
├─ Type: TypeError
|
||||
└─ Hint: Check if API server is running and CORS is configured
|
||||
```
|
||||
|
||||
And user notification (separate):
|
||||
```
|
||||
[USER-NOTIFICATION] Unable to connect to the server. Please check your internet connection.
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Developer-Friendly**: Beautiful colors, emojis, and formatting
|
||||
✅ **Reduced Noise**: Appropriate severity levels prevent spam
|
||||
✅ **Unified Format**: Same patterns as apps/api
|
||||
✅ **No Core Imports**: Website remains independent
|
||||
✅ **Professional**: Industry-standard logging practices
|
||||
✅ **Clean Output**: Human-readable only, no JSON clutter
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
1. **Server Down**: Should show warn level, no stack trace, retry attempts
|
||||
2. **CORS Error**: Should show warn level with troubleshooting hint
|
||||
3. **Auth Error (401)**: Should show warn level, retryable
|
||||
4. **Server Error (500)**: Should show error level with full details
|
||||
5. **Validation Error (400)**: Should show warn level, not retryable
|
||||
6. **Successful Call**: Should show info level with duration
|
||||
|
||||
## Quick Implementation
|
||||
|
||||
Run these commands to implement:
|
||||
|
||||
```bash
|
||||
# 1. Create interfaces
|
||||
mkdir -p apps/website/lib/interfaces
|
||||
cat > apps/website/lib/interfaces/Logger.ts << 'EOF'
|
||||
export interface Logger {
|
||||
debug(message: string, context?: unknown): void;
|
||||
info(message: string, context?: unknown): void;
|
||||
warn(message: string, context?: unknown): void;
|
||||
error(message: string, error?: Error, context?: unknown): void;
|
||||
}
|
||||
EOF
|
||||
|
||||
# 2. Create ErrorReporter interface
|
||||
cat > apps/website/lib/interfaces/ErrorReporter.ts << 'EOF'
|
||||
export interface ErrorReporter {
|
||||
report(error: Error, context?: unknown): void;
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3. Update ConsoleLogger (use the enhanced version above)
|
||||
# 4. Update ConsoleErrorReporter (use the version above)
|
||||
# 5. Update BaseApiClient (use the changes above)
|
||||
# 6. Update ApiRequestLogger (use the changes above)
|
||||
# 7. Update GlobalErrorHandler (use the changes above)
|
||||
```
|
||||
|
||||
This single plan provides everything needed to transform the logging from verbose and confusing to professional and beautiful! 🎨
|
||||
@@ -1,317 +0,0 @@
|
||||
# Admin Area Architecture Plan
|
||||
|
||||
## Overview
|
||||
Design and implement a clean architecture admin area for Owner and Super Admin roles to manage all users, following TDD principles.
|
||||
|
||||
## 1. Architecture Layers
|
||||
|
||||
### 1.1 Domain Layer (`core/admin/domain/`)
|
||||
**Purpose**: Business logic, entities, value objects, domain services - framework independent
|
||||
|
||||
#### Value Objects:
|
||||
- `UserId` - Unique identifier for users
|
||||
- `Email` - Validated email address
|
||||
- `UserRole` - Role type (owner, admin, user, etc.)
|
||||
- `UserStatus` - Account status (active, suspended, deleted)
|
||||
- `PermissionKey` - Capability identifier
|
||||
- `PermissionAction` - view | mutate
|
||||
|
||||
#### Entities:
|
||||
- `User` - Main entity with roles, status, email
|
||||
- `Permission` - Permission definition
|
||||
- `RolePermission` - Role to permission mapping
|
||||
|
||||
#### Domain Services:
|
||||
- `AuthorizationService` - Checks permissions and roles
|
||||
- `UserManagementService` - User lifecycle management rules
|
||||
|
||||
#### Domain Errors:
|
||||
- `UserDomainError` - Domain-specific errors
|
||||
- `AuthorizationError` - Permission violations
|
||||
|
||||
### 1.2 Application Layer (`core/admin/application/`)
|
||||
**Purpose**: Use cases, ports, orchestration - no framework dependencies
|
||||
|
||||
#### Ports (Interfaces):
|
||||
- **Input Ports**:
|
||||
- `IManageUsersUseCase` - User CRUD operations
|
||||
- `IManageRolesUseCase` - Role management
|
||||
- `IQueryUsersUseCase` - User queries
|
||||
|
||||
- **Output Ports**:
|
||||
- `IUserRepository` - Persistence interface
|
||||
- `IPermissionRepository` - Permission storage
|
||||
- `IUserPresenter` - Output formatting
|
||||
|
||||
#### Use Cases:
|
||||
- `ListUsersUseCase` - Get all users with filtering
|
||||
- `GetUserDetailsUseCase` - Get single user details
|
||||
- `UpdateUserRolesUseCase` - Modify user roles
|
||||
- `UpdateUserStatusUseCase` - Suspend/activate users
|
||||
- `DeleteUserUseCase` - Remove user accounts
|
||||
- `GetUserPermissionsUseCase` - View user permissions
|
||||
|
||||
#### Services:
|
||||
- `UserManagementService` - Orchestrates user operations
|
||||
- `PermissionEvaluationService` - Evaluates access rights
|
||||
|
||||
### 1.3 Infrastructure Layer (`adapters/admin/`)
|
||||
**Purpose**: Concrete implementations of ports
|
||||
|
||||
#### Persistence:
|
||||
- `TypeOrmUserRepository` - Database implementation
|
||||
- `TypeOrmPermissionRepository` - Permission storage
|
||||
- `InMemoryUserRepository` - Test implementation
|
||||
|
||||
#### Presenters:
|
||||
- `UserPresenter` - Maps domain to DTO
|
||||
- `UserListPresenter` - Formats user list
|
||||
- `PermissionPresenter` - Formats permissions
|
||||
|
||||
#### Security:
|
||||
- `RolePermissionMapper` - Maps roles to permissions
|
||||
- `AuthorizationGuard` - Enforces access control
|
||||
|
||||
### 1.4 API Layer (`apps/api/src/domain/admin/`)
|
||||
**Purpose**: HTTP delivery mechanism
|
||||
|
||||
#### Controllers:
|
||||
- `UsersController` - User management endpoints
|
||||
- `RolesController` - Role management endpoints
|
||||
- `PermissionsController` - Permission endpoints
|
||||
|
||||
#### DTOs:
|
||||
- **Request**:
|
||||
- `UpdateUserRolesRequestDto`
|
||||
- `UpdateUserStatusRequestDto`
|
||||
- `CreateUserRequestDto`
|
||||
|
||||
- **Response**:
|
||||
- `UserResponseDto`
|
||||
- `UserListResponseDto`
|
||||
- `PermissionResponseDto`
|
||||
|
||||
#### Middleware/Guards:
|
||||
- `RequireSystemAdminGuard` - Validates Owner/Super Admin access
|
||||
- `PermissionGuard` - Checks specific permissions
|
||||
|
||||
### 1.5 Frontend Layer (`apps/website/`)
|
||||
**Purpose**: User interface for admin operations
|
||||
|
||||
#### Pages:
|
||||
- `/admin/users` - User management dashboard
|
||||
- `/admin/users/[id]` - User detail page
|
||||
- `/admin/roles` - Role management
|
||||
- `/admin/permissions` - Permission matrix
|
||||
|
||||
#### Components:
|
||||
- `UserTable` - Display users with filters
|
||||
- `UserDetailCard` - User information display
|
||||
- `RoleSelector` - Role assignment UI
|
||||
- `StatusBadge` - User status display
|
||||
- `PermissionMatrix` - Permission management
|
||||
|
||||
#### API Clients:
|
||||
- `AdminUsersApiClient` - User management API calls
|
||||
- `AdminRolesApiClient` - Role management API calls
|
||||
|
||||
#### Hooks:
|
||||
- `useAdminUsers` - User data management
|
||||
- `useAdminPermissions` - Permission data
|
||||
|
||||
## 2. Authorization Model
|
||||
|
||||
### 2.1 System Roles (from AUTHORIZATION.md)
|
||||
- `owner` - Full system access
|
||||
- `admin` - System admin access
|
||||
|
||||
### 2.2 Permission Structure
|
||||
```
|
||||
capabilityKey.actionType
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `users.list` + `view`
|
||||
- `users.roles` + `mutate`
|
||||
- `users.status` + `mutate`
|
||||
|
||||
### 2.3 Role → Permission Mapping
|
||||
```
|
||||
owner: all permissions
|
||||
admin:
|
||||
- users.view
|
||||
- users.list
|
||||
- users.roles.mutate
|
||||
- users.status.mutate
|
||||
```
|
||||
|
||||
## 3. TDD Implementation Strategy
|
||||
|
||||
### 3.1 Test-First Approach
|
||||
1. **Write failing test** for domain entity/value object
|
||||
2. **Implement minimal code** to pass test
|
||||
3. **Refactor** while keeping tests green
|
||||
4. **Repeat** for each layer
|
||||
|
||||
### 3.2 Test Structure
|
||||
```
|
||||
core/admin/
|
||||
├── domain/
|
||||
│ ├── entities/
|
||||
│ │ ├── User.test.ts
|
||||
│ │ └── Permission.test.ts
|
||||
│ ├── value-objects/
|
||||
│ │ ├── UserId.test.ts
|
||||
│ │ ├── Email.test.ts
|
||||
│ │ ├── UserRole.test.ts
|
||||
│ │ └── UserStatus.test.ts
|
||||
│ └── services/
|
||||
│ ├── AuthorizationService.test.ts
|
||||
│ └── UserManagementService.test.ts
|
||||
├── application/
|
||||
│ ├── use-cases/
|
||||
│ │ ├── ListUsersUseCase.test.ts
|
||||
│ │ ├── UpdateUserRolesUseCase.test.ts
|
||||
│ │ └── UpdateUserStatusUseCase.test.ts
|
||||
│ └── services/
|
||||
│ └── UserManagementService.test.ts
|
||||
```
|
||||
|
||||
### 3.3 Integration Tests
|
||||
- API endpoint tests
|
||||
- End-to-end user management flows
|
||||
- Authorization guard tests
|
||||
|
||||
## 4. Implementation Phases
|
||||
|
||||
### Phase 1: Domain Layer (TDD)
|
||||
- [ ] Value objects with tests
|
||||
- [ ] Entities with tests
|
||||
- [ ] Domain services with tests
|
||||
|
||||
### Phase 2: Application Layer (TDD)
|
||||
- [ ] Ports/interfaces
|
||||
- [ ] Use cases with tests
|
||||
- [ ] Application services
|
||||
|
||||
### Phase 3: Infrastructure Layer (TDD)
|
||||
- [ ] Repository implementations
|
||||
- [ ] Presenters
|
||||
- [ ] Authorization guards
|
||||
|
||||
### Phase 4: API Layer (TDD)
|
||||
- [ ] Controllers
|
||||
- [ ] DTOs
|
||||
- [ ] Route definitions
|
||||
|
||||
### Phase 5: Frontend Layer
|
||||
- [ ] Pages
|
||||
- [ ] Components
|
||||
- [ ] API clients
|
||||
- [ ] Integration tests
|
||||
|
||||
### Phase 6: Integration & Verification
|
||||
- [ ] End-to-end tests
|
||||
- [ ] Authorization verification
|
||||
- [ ] TDD compliance check
|
||||
|
||||
## 5. Key Clean Architecture Rules
|
||||
|
||||
1. **Dependency Direction**: API → Application → Domain
|
||||
2. **No Framework Dependencies**: Domain/Application layers pure TypeScript
|
||||
3. **Ports & Adapters**: Clear interfaces between layers
|
||||
4. **Test Coverage**: All layers tested before implementation
|
||||
5. **Single Responsibility**: Each use case does one thing
|
||||
|
||||
## 6. User Management Features
|
||||
|
||||
### 6.1 List Users
|
||||
- Filter by role, status, email
|
||||
- Pagination support
|
||||
- Sort by various fields
|
||||
|
||||
### 6.2 User Details
|
||||
- View user information
|
||||
- See assigned roles
|
||||
- View account status
|
||||
- Audit trail
|
||||
|
||||
### 6.3 Role Management
|
||||
- Add/remove roles
|
||||
- Validate role assignments
|
||||
- Prevent privilege escalation
|
||||
|
||||
### 6.4 Status Management
|
||||
- Activate/suspend users
|
||||
- Soft delete support
|
||||
- Status history
|
||||
|
||||
### 6.5 Permission Management
|
||||
- View user permissions
|
||||
- Role-based permission matrix
|
||||
- Permission validation
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
### 7.1 Access Control
|
||||
- Only Owner/Admin can access admin area
|
||||
- Validate permissions on every operation
|
||||
- Audit all changes
|
||||
|
||||
### 7.2 Data Protection
|
||||
- Never expose sensitive data
|
||||
- Validate all inputs
|
||||
- Prevent self-modification of critical roles
|
||||
|
||||
### 7.3 Audit Trail
|
||||
- Log all user management actions
|
||||
- Track who changed what
|
||||
- Maintain history of changes
|
||||
|
||||
## 8. API Endpoints
|
||||
|
||||
### 8.1 User Management
|
||||
```
|
||||
GET /api/admin/users
|
||||
GET /api/admin/users/:id
|
||||
PUT /api/admin/users/:id/roles
|
||||
PUT /api/admin/users/:id/status
|
||||
DELETE /api/admin/users/:id
|
||||
```
|
||||
|
||||
### 8.2 Role Management
|
||||
```
|
||||
GET /api/admin/roles
|
||||
GET /api/admin/roles/:id
|
||||
POST /api/admin/roles
|
||||
PUT /api/admin/roles/:id
|
||||
DELETE /api/admin/roles/:id
|
||||
```
|
||||
|
||||
### 8.3 Permission Management
|
||||
```
|
||||
GET /api/admin/permissions
|
||||
GET /api/admin/permissions/:roleId
|
||||
PUT /api/admin/permissions/:roleId
|
||||
```
|
||||
|
||||
## 9. Frontend Routes
|
||||
|
||||
```
|
||||
/admin/users - User list
|
||||
/admin/users/:id - User detail
|
||||
/admin/roles - Role management
|
||||
/admin/permissions - Permission matrix
|
||||
```
|
||||
|
||||
## 10. Success Criteria
|
||||
|
||||
- [ ] All domain logic tested with TDD
|
||||
- [ ] All application use cases tested
|
||||
- [ ] API endpoints tested with integration tests
|
||||
- [ ] Frontend components work correctly
|
||||
- [ ] Authorization works for Owner and Super Admin only
|
||||
- [ ] Clean architecture boundaries maintained
|
||||
- [ ] No circular dependencies
|
||||
- [ ] All tests pass
|
||||
- [ ] Code follows project conventions
|
||||
@@ -1,517 +0,0 @@
|
||||
# API Use Case and Presenter Migration Todo List (Per File)
|
||||
|
||||
This todo list is structured per domain module and per file. It is intentionally free of code snippets and focuses only on the structural and behavioral changes required.
|
||||
|
||||
---
|
||||
|
||||
## Global cross-cutting tasks
|
||||
|
||||
- [ ] Ensure every migrated use case in the core returns a Result type and uses an output port to present its business result model.
|
||||
- [ ] Ensure all presenters live in the API layer, receive business result models from use cases via an output port, and store internal response models for the API.
|
||||
- [ ] Ensure all services in the API layer delegate mapping and response model construction to presenters and do not perform DTO or response model mapping themselves.
|
||||
- [ ] Ensure repositories and use cases are injected via dependency injection tokens only, never by direct class references.
|
||||
- [ ] Ensure presenters are never injected into services via dependency injection; presenters should be imported directly where needed and bound as output ports in modules.
|
||||
- [ ] Ensure use cases never perform serialization or DTO mapping; use cases operate on domain objects and result models only.
|
||||
- [ ] After each module migration, run type checking, linting, and targeted tests for that module.
|
||||
- [ ] After all modules are migrated, run full type checking, linting, and the entire test suite.
|
||||
|
||||
---
|
||||
|
||||
## Analytics domain module
|
||||
|
||||
Directory: apps/api/src/domain/analytics
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsController.ts
|
||||
- [x] Review all controller methods and update them to consume response models returned from the analytics service rather than constructing or interpreting DTOs manually.
|
||||
- [x] Ensure controller method signatures and return types reflect the new response model naming and structure introduced by presenters.
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsController.test.ts
|
||||
- [x] Update tests to assert that controller methods receive response models from the service and do not depend on internal mapping logic inside the service.
|
||||
- [x] Adjust expectations to align with response model terminology instead of any previous view model terminology.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsService.ts
|
||||
- [x] Identify each method that calls a core analytics use case and ensure it passes the use case result through the appropriate presenter via the output port.
|
||||
- [x] Remove any mapping or DTO-building logic from the service methods; move all such responsibilities into dedicated analytics presenters.
|
||||
- [x] Ensure each service method returns only the presenter’s response model, not any core domain objects or intermediate data.
|
||||
- [x] Verify that all injected dependencies are repositories and use cases injected via tokens, with no presenters injected via dependency injection.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsModule.ts
|
||||
- [x] Ensure the module declares analytics presenters as providers and binds them as implementations of the generic output port for the relevant use cases.
|
||||
- [x] Ensure that services and controllers are wired to use analytics use cases via tokens, not via direct class references.
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsModule.test.ts
|
||||
- [x] Update module-level tests to reflect the new provider wiring, especially the binding of presenters as output ports for use cases.
|
||||
|
||||
- File: apps/api/src/domain/analytics/AnalyticsProviders.ts
|
||||
- [x] Ensure all analytics repositories and use cases are exposed via clear token constants and that these tokens are used consistently in service constructors.
|
||||
- [x] Add or adjust any tokens required for use case output port injection, without introducing presenter tokens for services.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/analytics/dtos/GetAnalyticsMetricsOutputDTO.ts, GetDashboardDataOutputDTO.ts, RecordEngagementInputDTO.ts, RecordEngagementOutputDTO.ts, RecordPageViewInputDTO.ts, RecordPageViewOutputDTO.ts
|
||||
- [x] Verify that all analytics DTOs represent API-level input or response models only and are not used directly inside core use cases.
|
||||
- [x] Ensure naming reflects response model terminology where applicable and is consistent with presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts, GetDashboardDataPresenter.ts, RecordEngagementPresenter.ts, RecordPageViewPresenter.ts
|
||||
- [x] For each presenter, ensure it implements the use case output port contract for the corresponding analytics result model.
|
||||
- [x] Ensure each presenter maintains internal response model state that is constructed from the core result model.
|
||||
- [x] Ensure each presenter exposes a getter that returns the response model used by controllers or services.
|
||||
- [x] Move any analytics-specific mapping and transformation from services into these presenters.
|
||||
- [x] Align terminology within presenters to use response model rather than view model.
|
||||
|
||||
- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts, GetDashboardDataPresenter.test.ts, RecordEngagementPresenter.test.ts, RecordPageViewPresenter.test.ts
|
||||
- [x] Update tests to validate that each presenter receives a core result model, transforms it correctly, and exposes the correct response model.
|
||||
- [x] Ensure tests no longer assume mapping occurs in services; all mapping assertions should target presenter behavior.
|
||||
|
||||
---
|
||||
|
||||
## Auth domain module
|
||||
|
||||
Directory: apps/api/src/domain/auth
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthController.ts
|
||||
- [x] Review all controller methods and ensure they consume response models returned from the auth service, not raw domain objects or DTOs assembled by the controller.
|
||||
- [x] Align controller return types with the response models produced by auth presenters.
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthController.test.ts
|
||||
- [x] Update tests so they verify the controller’s interaction with the auth service in terms of response models and error handling consistent with use case Results.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthService.ts
|
||||
- [x] For signup, login, and logout operations, ensure the service only coordinates input, calls the corresponding core use cases, and retrieves response models from auth presenters.
|
||||
- [x] Remove all mapping logic in the service that translates between core user or session representations and API DTOs; move this logic into dedicated presenters.
|
||||
- [x] Ensure use cases are injected via tokens and that repositories and ports also use token-based injection.
|
||||
- [x] Ensure presenters are not injected into the service via dependency injection and are instead treated as part of the output port wiring and imported where necessary.
|
||||
- [x] Ensure each public service method returns a response model based on presenter state, not core domain entities.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthModule.ts
|
||||
- [x] Ensure the module declares auth presenters as providers and wires them as implementations of the use case output port for login, signup, and logout use cases.
|
||||
- [x] Confirm that the auth service and controller depend on use cases and ports via the defined tokens.
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthModule.test.ts
|
||||
- [x] Update module tests to reflect the new wiring of auth presenters as output ports and the absence of presenter injection into services.
|
||||
|
||||
- File: apps/api/src/domain/auth/AuthProviders.ts
|
||||
- [x] Verify that all tokens for auth repositories, services, and use cases are defined and consistently used.
|
||||
- [x] Add or adjust tokens required for output port injection, ensuring presenters themselves are not injected into services.
|
||||
|
||||
### DTOs
|
||||
|
||||
- File: apps/api/src/domain/auth/dtos/AuthDto.ts
|
||||
- [x] Ensure DTOs in this file represent API-level input and response models only and are not referenced by core use cases.
|
||||
- [x] Align DTO naming with response model terminology where applicable.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts, CommandResultPresenter.ts
|
||||
- [x] Ensure each presenter implements the generic use case output port contract for the relevant auth result model.
|
||||
- [x] Ensure each presenter maintains internal response model state derived from the core result model.
|
||||
- [x] Ensure a getter method is available to expose the response model to controllers and services.
|
||||
- [x] Move all auth-related mapping logic from the auth service into these presenters.
|
||||
- [x] Normalize terminology within presenters to use response model instead of view model.
|
||||
|
||||
- File: apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts
|
||||
- [x] Update tests so they validate that the auth session presenter receives core result models from auth use cases and correctly transforms them into auth response models.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard domain module
|
||||
|
||||
Directory: apps/api/src/domain/dashboard
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardController.ts
|
||||
- [x] Ensure controller methods depend on dashboard service methods that return response models, not core objects or partial mappings.
|
||||
- [x] Align method return types and expectations with the dashboard response models built by presenters.
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardController.test.ts
|
||||
- [x] Update tests to assert that the controller interacts with the service in terms of response models, not internal mapping behavior.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardService.ts
|
||||
- [x] Identify all dashboard service methods that construct or manipulate DTOs directly and move this logic into dashboard presenters.
|
||||
- [x] Ensure each service method calls the appropriate dashboard use case, allows it to drive presenters through output ports, and returns a response model obtained from presenters.
|
||||
- [x] Confirm that dashboard use cases and repositories are injected via tokens, with no presenters injected via dependency injection.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardModule.ts
|
||||
- [x] Ensure the module binds dashboard presenters as output port implementations for the relevant use cases.
|
||||
- [x] Ensure dashboard services depend on use cases via tokens only.
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardModule.test.ts
|
||||
- [x] Adjust tests to confirm correct provider wiring of presenters as output ports.
|
||||
|
||||
- File: apps/api/src/domain/dashboard/DashboardProviders.ts
|
||||
- [x] Review token definitions for repositories, services, and use cases; ensure all are used consistently in constructor injection.
|
||||
- [x] Add or adjust any tokens needed for output port wiring.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/dashboard/dtos/DashboardDriverSummaryDTO.ts, DashboardFeedItemSummaryDTO.ts, DashboardRaceSummaryDTO.ts
|
||||
- [x] Verify that these DTOs are used only as API-level response models from presenters or services and not within core use cases.
|
||||
- [x] Align naming and fields with the response models produced by dashboard presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- (Any dashboard presenters, when added or identified in the codebase)
|
||||
- [x] Ensure they implement the use case output port contract, maintain internal response model state, and expose response model getters.
|
||||
- [x] Move all dashboard mapping and DTO-building logic into these presenters.
|
||||
|
||||
---
|
||||
|
||||
## Driver domain module
|
||||
|
||||
Directory: apps/api/src/domain/driver
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/driver/DriverController.ts
|
||||
- [x] Ensure controller methods depend on driver service methods that return response models, not domain entities or partial DTOs.
|
||||
- [x] Align method signatures and return types with driver response models provided by presenters.
|
||||
|
||||
- File: apps/api/src/domain/driver/DriverController.test.ts
|
||||
- [x] Update tests so they verify controller interactions with the driver service via response models and error handling consistent with use case Results.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/driver/DriverService.ts
|
||||
- [x] Identify all mapping logic from driver domain objects to DTOs in the service and move that logic into driver presenters.
|
||||
- [x] Ensure each service method calls the relevant driver use case, lets the use case present results through presenters, and returns response models obtained from presenters.
|
||||
- [x] Confirm that repositories and use cases are injected via tokens, not via direct class references.
|
||||
- [x] Ensure no presenter is injected into the driver service via dependency injection.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/driver/DriverModule.ts
|
||||
- [x] Ensure driver presenters are registered as providers and are bound as output port implementations for driver use cases.
|
||||
- [x] Ensure the driver service and controller depend on use cases via tokens.
|
||||
|
||||
- File: apps/api/src/domain/driver/DriverModule.test.ts
|
||||
- [x] Update module tests to reflect the wiring of presenters as output ports and the token-based injection of use cases.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/driver/dtos/CompleteOnboardingInputDTO.ts, CompleteOnboardingOutputDTO.ts, DriverDTO.ts, DriverLeaderboardItemDTO.ts, DriverRegistrationStatusDTO.ts, DriversLeaderboardDTO.ts, DriverStatsDTO.ts, GetDriverOutputDTO.ts, GetDriverProfileOutputDTO.ts, GetDriverRegistrationStatusQueryDTO.ts
|
||||
- [x] Ensure these DTOs are used exclusively as API input or response models, and not inside core use cases.
|
||||
- [x] Align names and shapes with the response models and input expectations defined in driver presenters and services.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts, DriverPresenter.ts, DriverProfilePresenter.ts, DriverRegistrationStatusPresenter.ts, DriversLeaderboardPresenter.ts, DriverStatsPresenter.ts
|
||||
- [x] Ensure each presenter implements the use case output port contract for its driver result model.
|
||||
- [x] Ensure each presenter maintains an internal response model that is constructed from the driver result model.
|
||||
- [x] Ensure each presenter exposes a getter that returns the response model.
|
||||
- [x] Move all driver mapping logic from the driver service into the relevant presenters.
|
||||
- [x] Consistently use response model terminology within presenters.
|
||||
|
||||
- Files: apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.test.ts, DriversLeaderboardPresenter.test.ts, DriverStatsPresenter.test.ts
|
||||
- [x] Update tests to confirm that presenters correctly transform core result models into driver response models and that no mapping remains in the service.
|
||||
|
||||
---
|
||||
|
||||
## League domain module
|
||||
|
||||
Directory: apps/api/src/domain/league
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/league/LeagueController.ts
|
||||
- [ ] Ensure controller methods expect league response models from the league service and do not depend on internal DTO mapping logic.
|
||||
- [ ] Align return types with the league response models constructed by presenters.
|
||||
|
||||
- File: apps/api/src/domain/league/LeagueController.test.ts
|
||||
- [ ] Update tests to verify that controllers interact with the league service via response models and handle errors based on use case Results.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/league/LeagueService.ts
|
||||
- [ ] Identify all mapping and DTO construction logic in the league service and move it into league presenters.
|
||||
- [ ] Ensure service methods delegate output handling entirely to presenters and only return response models.
|
||||
- [ ] Ensure repositories and use cases are injected via tokens and that no presenter is injected via dependency injection.
|
||||
|
||||
- File: apps/api/src/domain/league/LeagueService.test.ts
|
||||
- [ ] Update tests to reflect the reduced responsibility of the league service and to verify that it returns response models produced by presenters.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- (If a LeagueModule file exists in the codebase)
|
||||
- [ ] Ensure league presenters are registered as providers and bound as output port implementations for league use cases.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts, ApproveLeagueJoinRequestDTO.ts, GetLeagueRacesOutputDTO.ts, GetLeagueWalletOutputDTO.ts, LeagueAdminDTO.ts, LeagueConfigFormModelDropPolicyDTO.ts, LeagueConfigFormModelStewardingDTO.ts, LeagueMemberDTO.ts, LeagueScoringPresetDTO.ts, MembershipStatusDTO.ts, RejectJoinRequestOutputDTO.ts, WithdrawFromLeagueWalletInputDTO.ts, WithdrawFromLeagueWalletOutputDTO.ts
|
||||
- [ ] Ensure these DTOs are used only as API-level representations and not referenced by core use cases.
|
||||
- [ ] Align naming with the response models produced by league presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts, LeagueScoringPresetsPresenter.ts
|
||||
- [ ] Ensure each presenter implements the use case output port contract for its league result model.
|
||||
- [ ] Ensure each presenter constructs and stores a league response model from the core result model and exposes a getter.
|
||||
- [ ] Move league mapping logic from the league service into these presenters.
|
||||
|
||||
- File: apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts
|
||||
- [ ] Update tests to confirm that the presenter is responsible for mapping from core result model to response model and that no mapping remains in the service.
|
||||
|
||||
---
|
||||
|
||||
## Media domain module
|
||||
|
||||
Directory: apps/api/src/domain/media
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/media/MediaController.ts
|
||||
- [x] Ensure controller methods depend on media service methods that return response models, not core media objects or partial DTOs.
|
||||
- [x] Align the controller return types with media response models produced by presenters.
|
||||
|
||||
- File: apps/api/src/domain/media/MediaController.test.ts
|
||||
- [x] Update tests to verify that controllers work with media response models returned from the service.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/media/MediaService.ts
|
||||
- [x] Identify all mapping from media domain objects to DTOs and move this logic into media presenters.
|
||||
- [x] Ensure each service method calls the relevant media use case, allows it to use presenters via output ports, and returns response models from presenters.
|
||||
- [x] Confirm that repositories and use cases are injected via tokens and that no presenter is injected via dependency injection.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/media/MediaModule.ts
|
||||
- [x] Ensure media presenters are registered as providers and bound as output port implementations for media use cases.
|
||||
|
||||
- File: apps/api/src/domain/media/MediaModule.test.ts
|
||||
- [x] Update tests to reflect the correct provider wiring and output port bindings.
|
||||
|
||||
- File: apps/api/src/domain/media/MediaProviders.ts
|
||||
- [x] Review token definitions for repositories and use cases; ensure they are used consistently for constructor injection.
|
||||
- [x] Add or adjust tokens required for output port wiring without introducing presenter tokens for services.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/media/dtos/DeleteMediaOutputDTO.ts, GetAvatarOutputDTO.ts, GetMediaOutputDTO.ts, RequestAvatarGenerationInputDTO.ts, RequestAvatarGenerationOutputDTO.ts, UpdateAvatarInputDTO.ts, UpdateAvatarOutputDTO.ts, UploadMediaInputDTO.ts, UploadMediaOutputDTO.ts
|
||||
- [x] Ensure these DTOs serve only as API input and response models and are not used directly within core use cases.
|
||||
- [x] Align naming and structure with the response models built by media presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts, GetAvatarPresenter.ts, GetMediaPresenter.ts, RequestAvatarGenerationPresenter.ts, UpdateAvatarPresenter.ts, UploadMediaPresenter.ts
|
||||
- [x] Ensure each presenter implements the use case output port contract for its media result model.
|
||||
- [x] Ensure each presenter maintains internal response model state derived from the core result model and exposes a getter.
|
||||
- [x] Move all mapping and response model construction from the media service into these presenters.
|
||||
|
||||
### Types
|
||||
|
||||
- Files: apps/api/src/domain/media/types/FacePhotoData.ts, SuitColor.ts
|
||||
- [x] Verify that these types are used appropriately as part of input or response models and not as replacements for core domain entities inside use cases.
|
||||
|
||||
---
|
||||
|
||||
## Payments domain module
|
||||
|
||||
Directory: apps/api/src/domain/payments
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/payments/PaymentsController.ts
|
||||
- [x] Ensure controller methods call payments use cases via services or directly, receive results that are presented via presenters, and return payments response models.
|
||||
- [x] Remove any mapping logic from the controller and rely exclusively on presenters for transforming result models into response models.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/payments/PaymentsModule.ts
|
||||
- [x] Ensure payments presenters are registered as providers and bound as output port implementations for payments use cases.
|
||||
|
||||
- File: apps/api/src/domain/payments/PaymentsModule.test.ts
|
||||
- [x] Update module tests to reflect correct output port wiring and token-based use case injection.
|
||||
|
||||
- File: apps/api/src/domain/payments/PaymentsProviders.ts
|
||||
- [x] Review token definitions for payments repositories and use cases; ensure they are consistently used for dependency injection.
|
||||
- [x] Add or adjust tokens as needed for output port wiring.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/payments/dtos/CreatePaymentInputDTO.ts, CreatePaymentOutputDTO.ts, MemberPaymentStatus.ts, MembershipFeeType.ts, PayerType.ts, PaymentDTO.ts, PaymentsDto.ts, PaymentStatus.ts, PaymentType.ts, PrizeType.ts, ReferenceType.ts, TransactionType.ts, UpdatePaymentStatusInputDTO.ts, UpdatePaymentStatusOutputDTO.ts
|
||||
- [x] Ensure these DTOs are used solely as API-level input and response models and not within core payments use cases.
|
||||
- [x] Align naming and structure with the response models and inputs expected by payments presenters and services.
|
||||
|
||||
### Presenters
|
||||
|
||||
- (Any payments presenters, once identified or added during implementation)
|
||||
- [x] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models.
|
||||
- [x] Centralize all payments mapping logic in these presenters.
|
||||
|
||||
---
|
||||
|
||||
## Protests domain module
|
||||
|
||||
Directory: apps/api/src/domain/protests
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/protests/ProtestsController.ts
|
||||
- [ ] Ensure controller methods rely on protests service methods that return response models, avoiding any direct mapping from domain objects.
|
||||
- [ ] Align controller return types with protests response models produced by presenters.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/protests/ProtestsService.ts
|
||||
- [ ] Identify all mapping logic in the protests service and move it into protests presenters.
|
||||
- [ ] Ensure each service method calls the appropriate protests use case, lets it use presenters through output ports, and returns response models from presenters.
|
||||
- [ ] Confirm that protests repositories and use cases are injected via tokens only.
|
||||
- [ ] Ensure no protests presenter is injected into the service via dependency injection.
|
||||
|
||||
- File: apps/api/src/domain/protests/ProtestsService.test.ts
|
||||
- [ ] Update tests to reflect the reduced responsibility of the protests service and its reliance on presenters for response model creation.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/protests/ProtestsModule.ts
|
||||
- [ ] Ensure protests presenters are registered as providers and bound as output port implementations for protests use cases.
|
||||
|
||||
- File: apps/api/src/domain/protests/ProtestsProviders.ts
|
||||
- [ ] Review token definitions and usage for protests repositories and use cases and ensure consistent usage for injection.
|
||||
- [ ] Add or adjust tokens for output port wiring.
|
||||
|
||||
### Presenters
|
||||
|
||||
- (Any protests presenters to be added or identified during implementation)
|
||||
- [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models.
|
||||
|
||||
---
|
||||
|
||||
## Race domain module
|
||||
|
||||
Directory: apps/api/src/domain/race
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/race/RaceController.ts
|
||||
- [x] Ensure controller methods call race service methods that return response models and do not perform any mapping from race domain entities.
|
||||
- [x] Adjust controller return types to reflect race response models created by presenters.
|
||||
|
||||
- File: apps/api/src/domain/race/RaceController.test.ts
|
||||
- [x] Update tests so they verify controller behavior in terms of response models and error handling based on use case Results.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/race/RaceService.ts
|
||||
- [x] Identify all mapping logic from race domain entities to DTOs and move it into race presenters.
|
||||
- [x] Ensure each service method calls the relevant race use case, lets it present through race presenters, and returns response models from presenters.
|
||||
- [x] Confirm race repositories and use cases are injected via tokens only and that no presenter is injected via dependency injection.
|
||||
|
||||
- File: apps/api/src/domain/race/RaceService.test.ts
|
||||
- [x] Update tests to reflect that the race service now delegates mapping to presenters and returns response models.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/race/RaceModule.ts
|
||||
- [x] Ensure race presenters are registered as providers and bound as output port implementations for race use cases.
|
||||
|
||||
- File: apps/api/src/domain/race/RaceModule.test.ts
|
||||
- [x] Update tests to confirm correct wiring of race presenters and token-based use case injection.
|
||||
|
||||
- File: apps/api/src/domain/race/RaceProviders.ts
|
||||
- [x] Verify token definitions for race repositories and use cases and ensure consistent usage.
|
||||
- [x] Add or adjust tokens to support output port wiring.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/race/dtos/AllRacesPageDTO.ts, DashboardDriverSummaryDTO.ts, DashboardFeedSummaryDTO.ts, DashboardFriendSummaryDTO.ts, DashboardLeagueStandingSummaryDTO.ts, DashboardOverviewDTO.ts, DashboardRaceSummaryDTO.ts, DashboardRecentResultDTO.ts, FileProtestCommandDTO.ts, GetRaceDetailParamsDTO.ts, ImportRaceResultsDTO.ts, ImportRaceResultsSummaryDTO.ts, QuickPenaltyCommandDTO.ts, RaceActionParamsDTO.ts, RaceDetailDTO.ts, RaceDetailEntryDTO.ts, RaceDetailLeagueDTO.ts, RaceDetailRaceDTO.ts, RaceDetailRegistrationDTO.ts, RaceDetailUserResultDTO.ts, RacePenaltiesDTO.ts, RacePenaltyDTO.ts, RaceProtestDTO.ts, RaceProtestsDTO.ts, RaceResultDTO.ts, RaceResultsDetailDTO.ts, RacesPageDataDTO.ts, RacesPageDataRaceDTO.ts, RaceStatsDTO.ts, RaceWithSOFDTO.ts, RegisterForRaceParamsDTO.ts, RequestProtestDefenseCommandDTO.ts, ReviewProtestCommandDTO.ts, WithdrawFromRaceParamsDTO.ts
|
||||
- [x] Ensure these DTOs serve exclusively as API-level input and response models and are not used directly by core race use cases.
|
||||
- [x] Align naming and structures with race response models produced by presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts, GetAllRacesPresenter.ts, GetTotalRacesPresenter.ts, ImportRaceResultsApiPresenter.ts, RaceDetailPresenter.ts, RacePenaltiesPresenter.ts, RaceProtestsPresenter.ts, RaceWithSOFPresenter.ts
|
||||
- [x] Ensure each race presenter implements the use case output port contract for its race result model.
|
||||
- [x] Ensure each presenter maintains internal response model state derived from core race result models and exposes getters.
|
||||
- [x] Move all race mapping logic and DTO construction from the race service into these presenters.
|
||||
- [x] Use response model terminology consistently within presenters.
|
||||
|
||||
- File: apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts
|
||||
- [x] Update tests so they validate presenter-based mapping from core race result models to race response models and reflect the absence of mapping logic in the service.
|
||||
|
||||
---
|
||||
|
||||
## Sponsor domain module
|
||||
|
||||
Directory: apps/api/src/domain/sponsor
|
||||
|
||||
### Controllers
|
||||
|
||||
- File: apps/api/src/domain/sponsor/SponsorController.ts
|
||||
- [ ] Ensure controller methods depend on sponsor service methods that return sponsor response models and avoid direct mapping.
|
||||
- [ ] Align controller return types with sponsor response models constructed by presenters.
|
||||
|
||||
### Services
|
||||
|
||||
- File: apps/api/src/domain/sponsor/SponsorService.ts
|
||||
- [ ] Identify and move all sponsor mapping and DTO construction from the service into sponsor presenters.
|
||||
- [ ] Ensure service methods call sponsor use cases, allow presenters to handle output via output ports, and return response models from presenters.
|
||||
- [ ] Confirm sponsor repositories and use cases are injected via tokens only, with no presenter injection.
|
||||
|
||||
- File: apps/api/src/domain/sponsor/SponsorService.test.ts
|
||||
- [ ] Update tests to reflect that sponsor services return response models created by presenters and no longer perform mapping.
|
||||
|
||||
### Module and providers
|
||||
|
||||
- File: apps/api/src/domain/sponsor/SponsorModule.ts
|
||||
- [ ] Ensure sponsor presenters are registered as providers and bound as output port implementations for sponsor use cases.
|
||||
|
||||
- File: apps/api/src/domain/sponsor/SponsorProviders.ts
|
||||
- [ ] Review token definitions and usage for sponsor repositories and use cases and adjust as needed for output port wiring.
|
||||
|
||||
### Presenters
|
||||
|
||||
- (Any sponsor presenters present or added during implementation)
|
||||
- [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models.
|
||||
|
||||
---
|
||||
|
||||
## Team domain module
|
||||
|
||||
Directory: apps/api/src/domain/team
|
||||
|
||||
### DTOs
|
||||
|
||||
- Files: apps/api/src/domain/team/dtos/GetTeamDetailsOutputDTO.ts, UpdateTeamOutputDTO.ts
|
||||
- [ ] Ensure these DTOs are used exclusively as API response models and are not referenced directly by core team use cases.
|
||||
- [ ] Align naming and fields with response models created by team presenters.
|
||||
|
||||
### Presenters
|
||||
|
||||
- Files: apps/api/src/domain/team/presenters/AllTeamsPresenter.ts, CreateTeamPresenter.ts, DriverTeamPresenter.ts, TeamDetailsPresenter.ts
|
||||
- [ ] Ensure each team presenter implements the use case output port contract for its corresponding team result model.
|
||||
- [ ] Ensure each presenter maintains internal response model state created from core team result models and exposes response model getters.
|
||||
- [ ] Move any team mapping or DTO-construction logic from any related services or controllers into these presenters.
|
||||
|
||||
### Services and controllers (if located elsewhere)
|
||||
|
||||
- (Any services or controllers that use the team presenters found in other modules)
|
||||
- [ ] Ensure these services and controllers treat team presenters as output ports, do not inject presenters directly, and return only response models from presenters.
|
||||
|
||||
---
|
||||
|
||||
## Final global verification
|
||||
|
||||
- [ ] Run project-wide type checking and resolve any remaining type errors related to use case output ports, presenters, and response models.
|
||||
- [ ] Run project-wide linting and fix all issues related to unused imports, incorrect injection tokens, or outdated DTO usage.
|
||||
- [ ] Run the full test suite and ensure that all module tests pass after the migration.
|
||||
- [ ] Perform a final review of the API layer to confirm that all mapping from domain objects to API representations is performed by presenters, that services are orchestration-only, and that use cases present via output ports and return Result types without performing serialization.
|
||||
@@ -1,180 +0,0 @@
|
||||
# Clean Architecture Compliant Auth Layer Design
|
||||
|
||||
## Current State Analysis (Diagnosis)
|
||||
- Existing [`core/identity/index.ts](core/identity/index.ts) exports User entity, IUserRepository port, but lacks credential-based auth ports (IAuthRepository), value objects (PasswordHash), and use cases (LoginUseCase, SignupUseCase).
|
||||
- iRacing-specific auth use cases present (StartAuthUseCase, HandleAuthCallbackUseCase), but no traditional email/password flows.
|
||||
- No domain services for password hashing/validation.
|
||||
- In-memory impls limited to ratings/achievements; missing User/Auth repo impls.
|
||||
- [`apps/website/lib/auth/InMemoryAuthService.ts](apps/website/lib/auth/InMemoryAuthService.ts) (visible) embeds business logic, violating dependency inversion.
|
||||
- App-layer AuthService exists but thick; no thin delivery via DI-injected use cases.
|
||||
- No AuthContext integration with clean AuthService.
|
||||
- DI setup in [`apps/website/lib/di-tokens.ts](apps/website/lib/di-tokens.ts) etc. needs auth bindings.
|
||||
|
||||
## Architectural Plan
|
||||
1. Add `PasswordHash` value object in `core/identity/domain/value-objects/PasswordHash.ts`.
|
||||
2. Create `IAuthRepository` port in `core/identity/domain/repositories/IAuthRepository.ts` with `findByCredentials(email: EmailAddress, passwordHash: PasswordHash): Promise<User | null>`, `save(user: User): Promise<void>`.
|
||||
3. Extend `IUserRepository` if needed for non-auth user ops.
|
||||
4. Implement `InMemoryUserRepository` in `adapters/identity/inmem/InMemoryUserRepository.ts` satisfying `IUserRepository & IAuthRepository`.
|
||||
5. Add domain service `PasswordHashingService` in `core/identity/domain/services/PasswordHashingService.ts` (interface + dummy impl).
|
||||
6. Create `LoginUseCase` in `core/identity/application/use-cases/LoginUseCase.ts` orchestrating repo find + hashing service.
|
||||
7. Create `SignupUseCase` in `core/identity/application/use-cases/SignupUseCase.ts` validating, hashing, save via repos.
|
||||
8. Create `GetUserUseCase` in `core/identity/application/use-cases/GetUserUseCase.ts` via IUserRepository.
|
||||
9. Refactor `apps/website/lib/auth/AuthService.ts` as thin adapter: DTO -> use case calls via injected deps.
|
||||
10. Update `apps/website/lib/auth/AuthContext.tsx` to provide/use AuthService via React Context.
|
||||
11. Add DI tokens in `apps/website/lib/di-tokens.ts`: `AUTH_REPOSITORY_TOKEN`, `USER_REPOSITORY_TOKEN`, `LOGIN_USE_CASE_TOKEN`, etc.
|
||||
12. Bind in `apps/website/lib/di-config.ts` / `di-container.ts`: ports -> inmem impls, use cases -> deps, AuthService -> use cases.
|
||||
|
||||
## Summary
|
||||
Layer auth into domain entities/VOs/services, application use cases/ports, infrastructure adapters (inmem), thin app delivery (AuthService) wired via DI. Coexists with existing iRacing provider auth.
|
||||
|
||||
## Design Overview
|
||||
Follows strict Clean Architecture:
|
||||
- **Entities**: User with EmailAddress, PasswordHash VOs.
|
||||
- **Use Cases**: Pure orchestrators, depend on ports/services.
|
||||
- **Ports**: IAuthRepository (credential ops), IUserRepository (user data).
|
||||
- **Adapters**: Inmem impls.
|
||||
- **Delivery**: AuthService maps HTTP/JS DTOs to use cases.
|
||||
- **DI**: Inversion via tokens/container.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
DTO[DTOs<br/>apps/website/lib/auth] --> AuthService[AuthService<br/>apps/website/lib/auth]
|
||||
AuthService --> LoginUC[LoginUseCase<br/>core/identity/application]
|
||||
AuthService --> SignupUC[SignupUseCase]
|
||||
LoginUC --> IAuthRepo[IAuthRepository<br/>core/identity/domain]
|
||||
SignupUC --> PasswordSvc[PasswordHashingService<br/>core/identity/domain]
|
||||
IAuthRepo --> InMemRepo[InMemoryUserRepository<br/>adapters/identity/inmem]
|
||||
AuthService -.-> DI[DI Container<br/>apps/website/lib/di-*]
|
||||
AuthContext[AuthContext.tsx] --> AuthService
|
||||
```
|
||||
|
||||
## Files Structure
|
||||
```
|
||||
core/identity/
|
||||
├── domain/
|
||||
│ ├── value-objects/PasswordHash.ts (new)
|
||||
│ ├── entities/User.ts (extend if needed)
|
||||
│ ├── repositories/IAuthRepository.ts (new)
|
||||
│ └── services/PasswordHashingService.ts (new)
|
||||
├── application/
|
||||
│ └── use-cases/LoginUseCase.ts (new)
|
||||
│ SignupUseCase.ts (new)
|
||||
│ GetUserUseCase.ts (new)
|
||||
adapters/identity/inmem/
|
||||
├── InMemoryUserRepository.ts (new)
|
||||
apps/website/lib/auth/
|
||||
├── AuthService.ts (refactor)
|
||||
└── AuthContext.tsx (update)
|
||||
apps/website/lib/
|
||||
├── di-tokens.ts (update)
|
||||
├── di-config.ts (update)
|
||||
└── di-container.ts (update)
|
||||
```
|
||||
|
||||
## Code Snippets
|
||||
|
||||
### PasswordHash VO
|
||||
```ts
|
||||
// core/identity/domain/value-objects/PasswordHash.ts
|
||||
import { ValueObject } from '../../../shared/domain/ValueObject'; // assume shared
|
||||
|
||||
export class PasswordHash extends ValueObject<string> {
|
||||
static create(plain: string): PasswordHash {
|
||||
// dummy bcrypt hash
|
||||
return new PasswordHash(btoa(plain)); // prod: use bcrypt
|
||||
}
|
||||
|
||||
verify(plain: string): boolean {
|
||||
return btoa(plain) === this.value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### IAuthRepository Port
|
||||
```ts
|
||||
// core/identity/domain/repositories/IAuthRepository.ts
|
||||
import { UserId, EmailAddress } from '../value-objects';
|
||||
import { User } from '../entities/User';
|
||||
import { PasswordHash } from '../value-objects/PasswordHash';
|
||||
|
||||
export interface IAuthRepository {
|
||||
findByCredentials(email: EmailAddress, passwordHash: PasswordHash): Promise<User | null>;
|
||||
save(user: User): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### LoginUseCase
|
||||
```ts
|
||||
// core/identity/application/use-cases/LoginUseCase.ts
|
||||
import { Injectable } from 'di'; // assume
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { User } from '../../domain/entities/User';
|
||||
|
||||
export class LoginUseCase {
|
||||
constructor(
|
||||
private authRepo: IAuthRepository
|
||||
) {}
|
||||
|
||||
async execute(email: string, password: string): Promise<User> {
|
||||
const emailVO = EmailAddress.create(email);
|
||||
const passwordHash = PasswordHash.create(password);
|
||||
const user = await this.authRepo.findByCredentials(emailVO, passwordHash);
|
||||
if (!user) throw new Error('Invalid credentials');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthService (Thin Adapter)
|
||||
```ts
|
||||
// apps/website/lib/auth/AuthService.ts
|
||||
import { injectable, inject } from 'di'; // assume
|
||||
import { LoginUseCase } from '@core/identity';
|
||||
import type { LoginDto } from '@core/identity/application/dto'; // define DTO
|
||||
|
||||
@injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@inject(LoginUseCase_TOKEN) private loginUC: LoginUseCase
|
||||
) {}
|
||||
|
||||
async login(credentials: {email: string, password: string}) {
|
||||
return await this.loginUC.execute(credentials.email, credentials.password);
|
||||
}
|
||||
// similar for signup, getUser
|
||||
}
|
||||
```
|
||||
|
||||
### DI Tokens Update
|
||||
```ts
|
||||
// apps/website/lib/di-tokens.ts
|
||||
export const LOGIN_USE_CASE_TOKEN = Symbol('LoginUseCase');
|
||||
export const AUTH_REPOSITORY_TOKEN = Symbol('IAuthRepository');
|
||||
// etc.
|
||||
```
|
||||
|
||||
### AuthContext Usage Example
|
||||
```tsx
|
||||
// apps/website/lib/auth/AuthContext.tsx
|
||||
import { createContext, useContext } from 'react';
|
||||
import { AuthService } from './AuthService';
|
||||
import { diContainer } from '../di-container';
|
||||
|
||||
const AuthContext = createContext<AuthService>(null!);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const authService = diContainer.resolve(AuthService);
|
||||
return <AuthContext.Provider value={authService}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
- Update `core/identity/index.ts` exports for new modules.
|
||||
- Update `core/identity/package.json` exports if needed.
|
||||
- Use dummy hashing for dev; prod adapter swaps repo impl.
|
||||
- No business logic in app/website: all in core use cases.
|
||||
- Coexists with iRacing auth: separate use cases/services.
|
||||
@@ -1,560 +0,0 @@
|
||||
# Auth Solution Finalization Plan
|
||||
|
||||
## Overview
|
||||
This plan outlines the comprehensive enhancement of the GridPilot authentication system to meet production requirements while maintaining clean architecture principles and supporting both in-memory and TypeORM implementations.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ What's Working
|
||||
- Clean Architecture with proper separation of concerns
|
||||
- Email/password signup and login
|
||||
- iRacing OAuth flow (placeholder)
|
||||
- Session management with cookies
|
||||
- Basic route protection (mode-based)
|
||||
- Dev tools overlay with demo login
|
||||
- In-memory and TypeORM persistence adapters
|
||||
|
||||
### ❌ What's Missing/Needs Enhancement
|
||||
1. **Real Name Validation**: Current system allows any displayName, but we need to enforce real names
|
||||
2. **Modern Auth Features**: No password reset, magic links, or modern recovery flows
|
||||
3. **Production-Ready Demo Login**: Current demo uses cookies but needs proper integration
|
||||
4. **Proper Route Protection**: Website middleware only checks app mode, not authentication status
|
||||
5. **Enhanced Error Handling**: Need better validation and user-friendly error messages
|
||||
6. **Security Hardening**: Need to ensure all endpoints are properly protected
|
||||
|
||||
## Enhanced Architecture Design
|
||||
|
||||
### 1. Domain Layer Changes
|
||||
|
||||
#### User Entity Updates
|
||||
```typescript
|
||||
// Enhanced validation for real names
|
||||
export class User {
|
||||
// ... existing properties
|
||||
|
||||
private validateDisplayName(displayName: string): void {
|
||||
const trimmed = displayName.trim();
|
||||
|
||||
// Must be a real name (no nicknames)
|
||||
if (trimmed.length < 2) {
|
||||
throw new Error('Name must be at least 2 characters');
|
||||
}
|
||||
|
||||
// No special characters except basic punctuation
|
||||
if (!/^[A-Za-z\s\-']{2,50}$/.test(trimmed)) {
|
||||
throw new Error('Name can only contain letters, spaces, hyphens, and apostrophes');
|
||||
}
|
||||
|
||||
// No common nickname patterns
|
||||
const nicknamePatterns = [/^user/i, /^test/i, /^[a-z0-9_]+$/i];
|
||||
if (nicknamePatterns.some(pattern => pattern.test(trimmed))) {
|
||||
throw new Error('Please use your real name, not a nickname');
|
||||
}
|
||||
|
||||
// Capitalize first letter of each word
|
||||
this.displayName = trimmed.replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### New Value Objects
|
||||
- `MagicLinkToken`: Secure token for password reset
|
||||
- `EmailVerificationToken`: For email verification (future)
|
||||
- `PasswordResetRequest`: Entity for tracking reset requests
|
||||
|
||||
#### New Repositories
|
||||
- `IMagicLinkRepository`: Store and validate magic links
|
||||
- `IPasswordResetRepository`: Track password reset requests
|
||||
|
||||
### 2. Application Layer Changes
|
||||
|
||||
#### New Use Cases
|
||||
```typescript
|
||||
// Forgot Password Use Case
|
||||
export class ForgotPasswordUseCase {
|
||||
async execute(email: string): Promise<Result<void, ApplicationError>> {
|
||||
// 1. Validate email exists
|
||||
// 2. Generate secure token
|
||||
// 3. Store token with expiration
|
||||
// 4. Send magic link email (or return link for dev)
|
||||
// 5. Rate limiting
|
||||
}
|
||||
}
|
||||
|
||||
// Reset Password Use Case
|
||||
export class ResetPasswordUseCase {
|
||||
async execute(token: string, newPassword: string): Promise<Result<void, ApplicationError>> {
|
||||
// 1. Validate token
|
||||
// 2. Check expiration
|
||||
// 3. Update password
|
||||
// 4. Invalidate token
|
||||
// 5. Clear other sessions
|
||||
}
|
||||
}
|
||||
|
||||
// Demo Login Use Case (Dev Only)
|
||||
export class DemoLoginUseCase {
|
||||
async execute(role: 'driver' | 'sponsor'): Promise<Result<AuthSession, ApplicationError>> {
|
||||
// 1. Check environment (dev only)
|
||||
// 2. Create demo user if doesn't exist
|
||||
// 3. Generate session
|
||||
// 4. Return session
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced Signup Use Case
|
||||
```typescript
|
||||
export class SignupUseCase {
|
||||
// Add real name validation
|
||||
// Add email format validation
|
||||
// Add password strength requirements
|
||||
// Optional: Email verification flow
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API Layer Changes
|
||||
|
||||
#### New Auth Endpoints
|
||||
```typescript
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
// Existing:
|
||||
// POST /auth/signup
|
||||
// POST /auth/login
|
||||
// GET /auth/session
|
||||
// POST /auth/logout
|
||||
// GET /auth/iracing/start
|
||||
// GET /auth/iracing/callback
|
||||
|
||||
// New:
|
||||
// POST /auth/forgot-password
|
||||
// POST /auth/reset-password
|
||||
// POST /auth/demo-login (dev only)
|
||||
// POST /auth/verify-email (future)
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced DTOs
|
||||
```typescript
|
||||
export class SignupParamsDTO {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Password must contain uppercase, lowercase, and number'
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z\s\-']{2,50}$/, {
|
||||
message: 'Please use your real name (letters, spaces, hyphens only)'
|
||||
})
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export class ForgotPasswordDTO {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordDTO {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export class DemoLoginDTO {
|
||||
@ApiProperty({ enum: ['driver', 'sponsor'] })
|
||||
role: 'driver' | 'sponsor';
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced Guards
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class AuthenticationGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Check session
|
||||
const session = await this.sessionPort.getCurrentSession();
|
||||
if (!session?.user?.id) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = { userId: session.user.id };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductionGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Block demo login in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
if (request.path === '/auth/demo-login') {
|
||||
throw new ForbiddenException('Demo login not available in production');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Website Layer Changes
|
||||
|
||||
#### Enhanced Middleware
|
||||
```typescript
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Public routes (always accessible)
|
||||
const publicRoutes = [
|
||||
'/',
|
||||
'/auth/login',
|
||||
'/auth/signup',
|
||||
'/auth/forgot-password',
|
||||
'/auth/reset-password',
|
||||
'/auth/iracing',
|
||||
'/auth/iracing/start',
|
||||
'/auth/iracing/callback',
|
||||
'/api/auth/signup',
|
||||
'/api/auth/login',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/demo-login', // dev only
|
||||
'/api/auth/session',
|
||||
'/api/auth/logout'
|
||||
];
|
||||
|
||||
// Protected routes (require authentication)
|
||||
const protectedRoutes = [
|
||||
'/dashboard',
|
||||
'/profile',
|
||||
'/leagues',
|
||||
'/races',
|
||||
'/teams',
|
||||
'/sponsor',
|
||||
'/onboarding'
|
||||
];
|
||||
|
||||
// Check if route is public
|
||||
if (publicRoutes.includes(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check if route is protected
|
||||
if (protectedRoutes.some(route => pathname.startsWith(route))) {
|
||||
// Verify authentication by calling API
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Add a header that can be checked by client components
|
||||
// This is a simple approach - in production, consider server-side session validation
|
||||
return response;
|
||||
}
|
||||
|
||||
// Allow other routes
|
||||
return NextResponse.next();
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Route Protection
|
||||
```typescript
|
||||
// Higher-order component for route protection
|
||||
export function withAuth<P extends object>(Component: React.ComponentType<P>) {
|
||||
return function ProtectedComponent(props: P) {
|
||||
const { session, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
router.push(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
|
||||
}
|
||||
}, [session, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null; // or redirecting indicator
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for protected data fetching
|
||||
export function useProtectedData<T>(fetcher: () => Promise<T>) {
|
||||
const { session, loading } = useAuth();
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
setError(new Error('Authentication required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch(setError);
|
||||
}
|
||||
}, [session, loading, fetcher]);
|
||||
|
||||
return { data, error, loading };
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced Auth Pages
|
||||
- **Login**: Add "Forgot Password" link
|
||||
- **Signup**: Add real name validation with helpful hints
|
||||
- **New**: Forgot Password page
|
||||
- **New**: Reset Password page
|
||||
- **New**: Magic Link landing page
|
||||
|
||||
#### Enhanced Dev Tools
|
||||
```typescript
|
||||
// Add proper demo login flow
|
||||
const handleDemoLogin = async (role: 'driver' | 'sponsor') => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/demo-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Demo login failed');
|
||||
|
||||
// Refresh session
|
||||
await refreshSession();
|
||||
|
||||
// Redirect based on role
|
||||
if (role === 'sponsor') {
|
||||
router.push('/sponsor/dashboard');
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Demo login failed:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Persistence Layer Changes
|
||||
|
||||
#### Enhanced Repositories
|
||||
Both InMemory and TypeORM implementations need to support:
|
||||
- Storing magic link tokens with expiration
|
||||
- Password reset request tracking
|
||||
- Rate limiting (failed login attempts)
|
||||
|
||||
#### Database Schema Updates (TypeORM)
|
||||
```typescript
|
||||
@Entity()
|
||||
export class MagicLinkToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column()
|
||||
token: string;
|
||||
|
||||
@Column()
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ default: false })
|
||||
used: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class PasswordResetRequest {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
token: string;
|
||||
|
||||
@Column()
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ default: false })
|
||||
used: boolean;
|
||||
|
||||
@Column({ default: 0 })
|
||||
attemptCount: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Security & Validation
|
||||
|
||||
#### Rate Limiting
|
||||
- Implement rate limiting on auth endpoints
|
||||
- Track failed login attempts
|
||||
- Lock accounts after too many failures
|
||||
|
||||
#### Input Validation
|
||||
- Email format validation
|
||||
- Password strength requirements
|
||||
- Real name validation
|
||||
- Token format validation
|
||||
|
||||
#### Environment Detection
|
||||
```typescript
|
||||
export function isDevelopment(): boolean {
|
||||
return process.env.NODE_ENV === 'development';
|
||||
}
|
||||
|
||||
export function isProduction(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
export function allowDemoLogin(): boolean {
|
||||
return isDevelopment() || process.env.ALLOW_DEMO_LOGIN === 'true';
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Integration Points
|
||||
|
||||
#### API Routes (Next.js)
|
||||
```typescript
|
||||
// app/api/auth/forgot-password/route.ts
|
||||
export async function POST(request: Request) {
|
||||
// Validate input
|
||||
// Call ForgotPasswordUseCase
|
||||
// Return appropriate response
|
||||
}
|
||||
|
||||
// app/api/auth/reset-password/route.ts
|
||||
export async function POST(request: Request) {
|
||||
// Validate token
|
||||
// Call ResetPasswordUseCase
|
||||
// Return success/error
|
||||
}
|
||||
|
||||
// app/api/auth/demo-login/route.ts (dev only)
|
||||
export async function POST(request: Request) {
|
||||
if (!allowDemoLogin()) {
|
||||
return NextResponse.json({ error: 'Not available' }, { status: 403 });
|
||||
}
|
||||
// Call DemoLoginUseCase
|
||||
}
|
||||
```
|
||||
|
||||
#### Website Components
|
||||
```typescript
|
||||
// ProtectedPageWrapper.tsx
|
||||
export function ProtectedPageWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { session, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!session) {
|
||||
router.push(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// AuthForm.tsx - Reusable form with validation
|
||||
// MagicLinkNotification.tsx - Show success message
|
||||
// PasswordStrengthMeter.tsx - Visual feedback
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Domain & Use Cases
|
||||
- [ ] Update User entity with real name validation
|
||||
- [ ] Create new use cases (ForgotPassword, ResetPassword, DemoLogin)
|
||||
- [ ] Create new repositories/interfaces
|
||||
- [ ] Add new value objects
|
||||
|
||||
### Phase 2: API Layer
|
||||
- [ ] Add new auth endpoints
|
||||
- [ ] Create new DTOs with validation
|
||||
- [ ] Update existing endpoints with enhanced validation
|
||||
- [ ] Add guards and middleware
|
||||
|
||||
### Phase 3: Persistence
|
||||
- [ ] Update InMemory repositories
|
||||
- [ ] Update TypeORM repositories
|
||||
- [ ] Add database migrations (if needed)
|
||||
- [ ] Implement rate limiting storage
|
||||
|
||||
### Phase 4: Website Integration
|
||||
- [ ] Update middleware for proper route protection
|
||||
- [ ] Create new auth pages (forgot password, reset)
|
||||
- [ ] Enhance existing pages with validation
|
||||
- [ ] Update dev tools overlay
|
||||
- [ ] Add client-side route protection HOCs
|
||||
|
||||
### Phase 5: Testing & Documentation
|
||||
- [ ] Write unit tests for new use cases
|
||||
- [ ] Write integration tests for new endpoints
|
||||
- [ ] Test both in-memory and TypeORM implementations
|
||||
- [ ] Update API documentation
|
||||
- [ ] Update architecture docs
|
||||
|
||||
## Key Requirements Checklist
|
||||
|
||||
### ✅ Must Work With
|
||||
- [ ] InMemory implementation
|
||||
- [ ] TypeORM implementation
|
||||
- [ ] Dev tools overlay
|
||||
- [ ] Existing session management
|
||||
|
||||
### ✅ Must Provide
|
||||
- [ ] Demo login for dev (not production)
|
||||
- [ ] Forgot password solution (modern approach)
|
||||
- [ ] Real name validation (no nicknames)
|
||||
- [ ] Proper website route protection
|
||||
|
||||
### ✅ Must Not Break
|
||||
- [ ] Existing signup/login flow
|
||||
- [ ] iRacing OAuth flow
|
||||
- [ ] Existing tests
|
||||
- [ ] Clean architecture principles
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Security**: All protected routes require authentication
|
||||
2. **User Experience**: Clear validation messages, helpful error states
|
||||
3. **Developer Experience**: Easy demo login, clear separation of concerns
|
||||
4. **Maintainability**: Clean architecture, well-tested, documented
|
||||
5. **Scalability**: Works with both in-memory and database persistence
|
||||
|
||||
## Notes
|
||||
|
||||
- The demo login should be clearly marked as development-only
|
||||
- Magic links should have short expiration times (15-30 minutes)
|
||||
- Consider adding email verification as a future enhancement
|
||||
- Rate limiting should be configurable per environment
|
||||
- All new features should follow the existing clean architecture patterns
|
||||
@@ -1,324 +0,0 @@
|
||||
# Auth Solution Finalization Summary
|
||||
|
||||
## Overview
|
||||
Successfully finalized the authentication solution according to the architecture documentation, implementing modern features while maintaining compatibility with both in-memory and TypeORM implementations.
|
||||
|
||||
## ✅ Completed Implementation
|
||||
|
||||
### 1. Domain Layer Enhancements
|
||||
|
||||
#### User Entity (Real Name Validation)
|
||||
- **Location**: `./adapters/identity/User.ts`
|
||||
- **Changes**: Enhanced constructor with strict real name validation
|
||||
- **Validation Rules**:
|
||||
- Minimum 2 characters, maximum 50
|
||||
- Only letters, spaces, hyphens, apostrophes
|
||||
- Blocks common nickname patterns (user, test, demo, guest, player)
|
||||
- Auto-capitalizes first letter of each word
|
||||
- Prevents multiple consecutive spaces
|
||||
|
||||
#### Magic Link Repository Interface
|
||||
- **Location**: `./adapters/identity/IMagicLinkRepository.ts`
|
||||
- **Purpose**: Abstract interface for password reset tokens
|
||||
- **Methods**: `create()`, `validate()`, `consume()`
|
||||
|
||||
### 2. Application Layer - New Use Cases
|
||||
|
||||
#### ForgotPasswordUseCase
|
||||
- **Location**: `./apps/api/src/domain/auth/usecases/ForgotPasswordUseCase.ts`
|
||||
- **Features**:
|
||||
- Generates 32-byte secure tokens
|
||||
- 15-minute expiration
|
||||
- Rate limiting (3 attempts per 15 minutes)
|
||||
- Returns magic link for development
|
||||
- Production-ready for email integration
|
||||
|
||||
#### ResetPasswordUseCase
|
||||
- **Location**: `./apps/api/src/domain/auth/usecases/ResetPasswordUseCase.ts`
|
||||
- **Features**:
|
||||
- Validates token expiration
|
||||
- Updates password securely
|
||||
- Consumes single-use tokens
|
||||
- Proper error handling
|
||||
|
||||
#### DemoLoginUseCase
|
||||
- **Location**: `./apps/api/src/domain/auth/usecases/DemoLoginUseCase.ts`
|
||||
- **Features**:
|
||||
- Development-only (blocked in production)
|
||||
- Creates demo users if needed
|
||||
- Role-based (driver, sponsor, league-admin)
|
||||
- Returns proper session tokens
|
||||
|
||||
### 3. API Layer - Controllers & Services
|
||||
|
||||
#### Updated AuthController
|
||||
- **Location**: `./apps/api/src/domain/auth/AuthController.ts`
|
||||
- **New Endpoints**:
|
||||
- `POST /auth/forgot-password` - Request password reset
|
||||
- `POST /auth/reset-password` - Reset with token
|
||||
- `POST /auth/demo-login` - Development login
|
||||
- **ProductionGuard**: Blocks demo login in production
|
||||
|
||||
#### Enhanced AuthService
|
||||
- **Location**: `./apps/api/src/domain/auth/AuthService.ts`
|
||||
- **New Methods**:
|
||||
- `forgotPassword()` - Handles reset requests
|
||||
- `resetPassword()` - Processes token-based reset
|
||||
- `demoLogin()` - Development authentication
|
||||
|
||||
#### New DTOs
|
||||
- **Location**: `./apps/api/src/domain/auth/dtos/`
|
||||
- **Files**:
|
||||
- `ForgotPasswordDTO.ts` - Email validation
|
||||
- `ResetPasswordDTO.ts` - Token + password validation
|
||||
- `DemoLoginDTO.ts` - Role-based demo login
|
||||
|
||||
### 4. Persistence Layer
|
||||
|
||||
#### InMemory Implementation
|
||||
- **Location**: `./adapters/identity/InMemoryMagicLinkRepository.ts`
|
||||
- **Features**:
|
||||
- Rate limiting with sliding window
|
||||
- Token expiration checking
|
||||
- Single-use enforcement
|
||||
- In-memory storage
|
||||
|
||||
#### TypeORM Implementation
|
||||
- **Location**: `./adapters/identity/TypeOrmMagicLinkRepository.ts`
|
||||
- **Entity**: `./adapters/identity/entities/PasswordResetRequest.ts`
|
||||
- **Features**:
|
||||
- Database persistence
|
||||
- Automatic cleanup of expired tokens
|
||||
- Foreign key to users table
|
||||
- Indexes for performance
|
||||
|
||||
### 5. Website Layer - Frontend
|
||||
|
||||
#### Route Protection Middleware
|
||||
- **Location**: `./apps/website/middleware.ts`
|
||||
- **Features**:
|
||||
- Public routes always accessible
|
||||
- Protected routes require authentication
|
||||
- Demo mode support
|
||||
- Non-disclosure (404) for unauthorized access
|
||||
- Proper redirect handling
|
||||
|
||||
#### Updated Auth Pages
|
||||
|
||||
**Login Page** (`./apps/website/app/auth/login/page.tsx`)
|
||||
- Uses `AuthService` instead of direct fetch
|
||||
- Forgot password link
|
||||
- Demo login via API
|
||||
- Real-time session refresh
|
||||
|
||||
**Signup Page** (`./apps/website/app/auth/signup/page.tsx`)
|
||||
- Real name validation in UI
|
||||
- Password strength requirements
|
||||
- Demo login via API
|
||||
- Service-based submission
|
||||
|
||||
**Forgot Password Page** (`./apps/website/app/auth/forgot-password/page.tsx`)
|
||||
- Email validation
|
||||
- Magic link display in development
|
||||
- Success state with instructions
|
||||
- Service-based submission
|
||||
|
||||
**Reset Password Page** (`./apps/website/app/auth/reset-password/page.tsx`)
|
||||
- Token extraction from URL
|
||||
- Password strength validation
|
||||
- Confirmation matching
|
||||
- Service-based submission
|
||||
|
||||
#### Auth Service Client
|
||||
- **Location**: `./apps/website/lib/services/AuthService.ts`
|
||||
- **Methods**:
|
||||
- `signup()` - Real name validation
|
||||
- `login()` - Email/password auth
|
||||
- `logout()` - Session cleanup
|
||||
- `forgotPassword()` - Magic link request
|
||||
- `resetPassword()` - Token-based reset
|
||||
- `demoLogin()` - Development auth
|
||||
- `getSession()` - Current user info
|
||||
|
||||
#### Service Factory
|
||||
- **Location**: `./apps/website/lib/services/ServiceFactory.ts`
|
||||
- **Purpose**: Creates service instances with API base URL
|
||||
- **Configurable**: Works with different environments
|
||||
|
||||
#### Dev Toolbar Updates
|
||||
- **Location**: `./apps/website/components/dev/DevToolbar.tsx`
|
||||
- **Changes**: Uses API endpoints instead of just cookies
|
||||
- **Features**:
|
||||
- Driver/Sponsor role switching
|
||||
- Logout functionality
|
||||
- Notification testing
|
||||
- Development-only display
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### Password Requirements
|
||||
- Minimum 8 characters
|
||||
- Must contain uppercase, lowercase, and numbers
|
||||
- Special characters recommended
|
||||
- Strength indicator in UI
|
||||
|
||||
### Token Security
|
||||
- 32-byte cryptographically secure tokens
|
||||
- 15-minute expiration
|
||||
- Single-use enforcement
|
||||
- Rate limiting (3 attempts per 15 minutes)
|
||||
|
||||
### Production Safety
|
||||
- Demo login blocked in production
|
||||
- Proper error messages (no sensitive info leakage)
|
||||
- Secure session management
|
||||
- CSRF protection via same-site cookies
|
||||
|
||||
## 🎯 Architecture Compliance
|
||||
|
||||
### Clean Architecture Principles
|
||||
- ✅ Domain entities remain pure
|
||||
- ✅ Use cases handle business logic
|
||||
- ✅ Controllers handle HTTP translation
|
||||
- ✅ Presenters handle output formatting
|
||||
- ✅ Repositories handle persistence
|
||||
- ✅ No framework dependencies in core
|
||||
|
||||
### Dependency Flow
|
||||
```
|
||||
Website → API Client → API Controller → Use Case → Repository → Database
|
||||
```
|
||||
|
||||
### Separation of Concerns
|
||||
- **Domain**: Business rules and entities
|
||||
- **Application**: Use cases and orchestration
|
||||
- **Infrastructure**: HTTP, Database, External services
|
||||
- **Presentation**: UI and user interaction
|
||||
|
||||
## 🔄 Compatibility
|
||||
|
||||
### In-Memory Implementation
|
||||
- ✅ User repository
|
||||
- ✅ Magic link repository
|
||||
- ✅ Session management
|
||||
- ✅ All use cases work
|
||||
|
||||
### TypeORM Implementation
|
||||
- ✅ User repository
|
||||
- ✅ Magic link repository
|
||||
- ✅ Session management
|
||||
- ✅ All use cases work
|
||||
|
||||
### Environment Support
|
||||
- ✅ Development (demo login, magic links)
|
||||
- ✅ Production (demo blocked, email-ready)
|
||||
- ✅ Test (isolated instances)
|
||||
|
||||
## 📋 API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/signup` - Create account (real name required)
|
||||
- `POST /auth/login` - Email/password login
|
||||
- `POST /auth/logout` - End session
|
||||
- `GET /auth/session` - Get current session
|
||||
|
||||
### Password Management
|
||||
- `POST /auth/forgot-password` - Request reset link
|
||||
- `POST /auth/reset-password` - Reset with token
|
||||
|
||||
### Development
|
||||
- `POST /auth/demo-login` - Demo user login (dev only)
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Testing (Pending)
|
||||
- [ ] Unit tests for new use cases
|
||||
- [ ] Integration tests for auth flows
|
||||
- [ ] E2E tests for user journeys
|
||||
- [ ] Security tests for token handling
|
||||
|
||||
### Documentation (Pending)
|
||||
- [ ] Update API documentation
|
||||
- [ ] Add auth flow diagrams
|
||||
- [ ] Document environment variables
|
||||
- [ ] Add deployment checklist
|
||||
|
||||
### Production Deployment
|
||||
- [ ] Configure email service
|
||||
- [ ] Set up HTTPS
|
||||
- [ ] Configure CORS
|
||||
- [ ] Set up monitoring
|
||||
- [ ] Add rate limiting middleware
|
||||
|
||||
## 🎨 User Experience
|
||||
|
||||
### Signup Flow
|
||||
1. User enters real name (validated)
|
||||
2. Email and password (strength checked)
|
||||
3. Role selection
|
||||
4. Immediate session creation
|
||||
5. Redirect to onboarding/dashboard
|
||||
|
||||
### Password Reset Flow
|
||||
1. User requests reset via email
|
||||
2. Receives magic link (or copy-paste in dev)
|
||||
3. Clicks link to reset password page
|
||||
4. Enters new password (strength checked)
|
||||
5. Password updated, auto-logged in
|
||||
|
||||
### Demo Login Flow
|
||||
1. Click "Demo Login" (dev only)
|
||||
2. Select role (driver/sponsor/league-admin)
|
||||
3. Instant login with demo user
|
||||
4. Full app access for testing
|
||||
|
||||
## ✨ Key Improvements
|
||||
|
||||
1. **Real Names Only**: No more nicknames, better identity verification
|
||||
2. **Modern Auth**: Magic links instead of traditional password reset
|
||||
3. **Developer Experience**: Demo login without setup
|
||||
4. **Security**: Rate limiting, token expiration, single-use tokens
|
||||
5. **UX**: Password strength indicators, clear validation messages
|
||||
6. **Architecture**: Clean separation, testable, maintainable
|
||||
|
||||
## 📦 Files Modified/Created
|
||||
|
||||
### Core Domain
|
||||
- `./adapters/identity/User.ts` (enhanced)
|
||||
- `./adapters/identity/IMagicLinkRepository.ts` (new)
|
||||
- `./adapters/identity/InMemoryMagicLinkRepository.ts` (new)
|
||||
- `./adapters/identity/TypeOrmMagicLinkRepository.ts` (new)
|
||||
- `./adapters/identity/entities/PasswordResetRequest.ts` (new)
|
||||
|
||||
### API Layer
|
||||
- `./apps/api/src/domain/auth/AuthController.ts` (updated)
|
||||
- `./apps/api/src/domain/auth/AuthService.ts` (updated)
|
||||
- `./apps/api/src/domain/auth/usecases/ForgotPasswordUseCase.ts` (new)
|
||||
- `./apps/api/src/domain/auth/usecases/ResetPasswordUseCase.ts` (new)
|
||||
- `./apps/api/src/domain/auth/usecases/DemoLoginUseCase.ts` (new)
|
||||
- `./apps/api/src/domain/auth/dtos/*.ts` (new DTOs)
|
||||
- `./apps/api/src/domain/auth/presenters/*.ts` (new presenters)
|
||||
|
||||
### Website Layer
|
||||
- `./apps/website/middleware.ts` (updated)
|
||||
- `./apps/website/lib/services/AuthService.ts` (updated)
|
||||
- `./apps/website/lib/services/ServiceFactory.ts` (new)
|
||||
- `./apps/website/app/auth/login/page.tsx` (updated)
|
||||
- `./apps/website/app/auth/signup/page.tsx` (updated)
|
||||
- `./apps/website/app/auth/forgot-password/page.tsx` (new)
|
||||
- `./apps/website/app/auth/reset-password/page.tsx` (new)
|
||||
- `./apps/website/components/dev/DevToolbar.tsx` (updated)
|
||||
|
||||
## 🎯 Success Criteria Met
|
||||
|
||||
✅ **Works with in-memory and TypeORM** - Both implementations complete
|
||||
✅ **Works with dev tools overlay** - Demo login via API
|
||||
✅ **Demo login for dev** - Development-only, secure
|
||||
✅ **Forgot password solution** - Modern magic link approach
|
||||
✅ **No nicknames** - Real name validation enforced
|
||||
✅ **Website blocks protected routes** - Middleware updated
|
||||
✅ **Clean Architecture** - All principles followed
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ COMPLETE - Ready for testing and deployment
|
||||
@@ -1,169 +0,0 @@
|
||||
# League Admin MVP Plan (Admin Acquisition)
|
||||
|
||||
## Goal
|
||||
Finish all league-management tools needed to attract and retain league admins by making three workflows fully functional, permission-correct, and data-backed:
|
||||
- Schedule builder + publishing
|
||||
- Roster + join requests + roles
|
||||
- Results import + standings recompute
|
||||
|
||||
This plan prioritizes fixing auth/permissions and API contracts first, because they block all three workflows.
|
||||
|
||||
---
|
||||
|
||||
## What we observed (current state)
|
||||
### Website gaps (data + correctness)
|
||||
- Schedule UI assumes a rich race object but contract is effectively `unknown[]` and uses `Date` methods; this is unsafe and likely to break on real API responses.
|
||||
- Standings page uses real standings + memberships, but fills missing stats fields with placeholders (avgFinish, penaltyPoints, bonusPoints, racesStarted).
|
||||
- League settings page checks admin via a membership cache, which can falsely deny access if cache isn’t hydrated.
|
||||
- Stewarding data fetch is N+1: for each race, fetch protests + penalties, which won’t scale.
|
||||
- Wallet page is explicitly demo/prototype: hardcoded season/account and static breakdown data; also not admin-gated.
|
||||
|
||||
### API gaps (permissions + contract + admin tooling)
|
||||
- Actor identity is inconsistent:
|
||||
- Some operations take a performer/admin ID from request data.
|
||||
- Some operations hardcode an admin ID.
|
||||
- Some areas infer identity from session.
|
||||
- Swagger/OpenAPI generation exists in code, but the committed OpenAPI artifact is stale/empty, so it cannot serve as a reliable contract source right now.
|
||||
- League schedule endpoint exists, but it does not appear to deliver a typed, UI-ready schedule contract that the website expects.
|
||||
- League admin tooling endpoints exist in fragments (join requests, memberships, config, wallet, protests), but are missing end-to-end admin workflows (schedule editing/publishing, results import flows, etc.) and consistent authorization.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (MVP-wide)
|
||||
1. Every admin action is authorized server-side based on the authenticated session identity (no client-supplied performer IDs; no hardcoded admin IDs).
|
||||
2. Website uses stable, generated types for API DTOs; no `unknown[]` schedule data.
|
||||
3. Admin can:
|
||||
- Create and publish a season schedule (add/edit/remove races).
|
||||
- Manage roster and join requests (approve/reject, roles, remove members; enforce capacity).
|
||||
- Import results and see standings update per season.
|
||||
4. Performance guardrails:
|
||||
- No N+1 requests for league stewarding over races; provide aggregate endpoint(s).
|
||||
5. Quality gates pass in implementation phase: lint, typecheck, tests.
|
||||
|
||||
---
|
||||
|
||||
## Gap matrix (workflow → missing pieces)
|
||||
### 1) Auth/Permissions (cross-cutting)
|
||||
Missing / must improve:
|
||||
- A single canonical “actor” model (session userId vs driverId mapping).
|
||||
- Consistent admin/owner authorization checks for all league write operations.
|
||||
- Removal of performer IDs from all public contracts.
|
||||
|
||||
Dependencies:
|
||||
- Session endpoint exists; need to decide how driver identity is represented in session and how it’s resolved.
|
||||
|
||||
Deliverable:
|
||||
- A short doc describing actor model and permission rules, then code changes that enforce them.
|
||||
|
||||
### 2) Schedule builder + publishing
|
||||
Missing / must improve:
|
||||
- Contract: schedule DTO must be typed and use ISO strings for dates; website parses to Date in view models.
|
||||
- Admin endpoints: create/update/delete schedule races, associate to season, publish/unpublish.
|
||||
- UI: schedule admin interface for managing races.
|
||||
- Driver registration status: schedule should reflect registration per driver without relying on ad-hoc “isRegistered” fields.
|
||||
|
||||
Deliverable:
|
||||
- Season-aware schedule read endpoint + schedule write endpoints + website schedule editor.
|
||||
|
||||
### 3) Roster + join requests + roles
|
||||
Missing / must improve:
|
||||
- Join requests list exists, but approval/rejection must be permission-correct and actor-derived.
|
||||
- Role changes and member removal must be actor-derived.
|
||||
- UI: admin roster page (requests inbox + members list + role controls + remove).
|
||||
- Capacity/status enforcement at the API layer.
|
||||
|
||||
Deliverable:
|
||||
- A single roster admin experience that matches API rules.
|
||||
|
||||
### 4) Results import + standings recompute
|
||||
Missing / must improve:
|
||||
- Results import UX in website (admin flow) and stable API contract(s) for import + recompute.
|
||||
- Standings should be season-aware and include fields the UI currently fakes or omits.
|
||||
- Ensure penalties/protests can affect standings where applicable.
|
||||
|
||||
Deliverable:
|
||||
- Admin results import page + standings page backed by season-aware API.
|
||||
|
||||
### 5) Stewarding
|
||||
Missing / must improve:
|
||||
- Aggregate league stewarding endpoint (races + protests + penalties) to avoid N+1 behavior.
|
||||
- Confirm admin-only access and correct actor inference for review/apply penalty.
|
||||
|
||||
Deliverable:
|
||||
- Single endpoint powering stewarding page, plus minimal UI updates.
|
||||
|
||||
### 6) Wallet (scope decision required)
|
||||
Recommendation:
|
||||
- Keep wallet “demo-only” for MVP, but make it permission-correct and remove hardcoded season/account IDs.
|
||||
- Replace static breakdown sections with values derived from the wallet endpoint, or hide them behind a “coming soon” section.
|
||||
|
||||
Deliverable:
|
||||
- Admin-only wallet access + remove hardcoded values + clearly labeled non-MVP parts.
|
||||
|
||||
---
|
||||
|
||||
## Proposed execution plan (implementation-ready)
|
||||
### Phase 0 — Contract & identity foundation (must be first)
|
||||
- Define the actor model:
|
||||
- What the session contains (userId, driverId, roles).
|
||||
- How userId maps to driverId (1:1 or indirect).
|
||||
- What “league admin” means and where it’s validated.
|
||||
- Update all league write endpoints to infer actor from session and enforce permissions.
|
||||
- Remove any hardcoded actor IDs in services.
|
||||
- Make OpenAPI generation reliable and used as contract source.
|
||||
|
||||
Acceptance criteria:
|
||||
- No API route accepts performer/admin IDs for authorization.
|
||||
- OpenAPI doc contains real paths and is regeneratable in CI/local.
|
||||
|
||||
### Phase 1 — Normalize DTOs + website type safety
|
||||
- Fix website type generation flow so generated DTOs exist and match API.
|
||||
- Fix schedule DTO contract:
|
||||
- Race schedule entries use ISO strings.
|
||||
- Website parses and derives isPast/isUpcoming deterministically.
|
||||
- Registration state is returned explicitly or derived via a separate endpoint.
|
||||
|
||||
Acceptance criteria:
|
||||
- No schedule-related `unknown` casts remain.
|
||||
- Schedule page renders with real API data and correct date handling.
|
||||
|
||||
### Phase 2 — Admin schedule management
|
||||
- Implement schedule CRUD endpoints for admins (season-scoped).
|
||||
- Build schedule editor UI (create/edit/delete/publish).
|
||||
- Ensure driver registration still works.
|
||||
|
||||
Acceptance criteria:
|
||||
- Admin can publish a schedule and members see it immediately.
|
||||
|
||||
### Phase 3 — Roster and join requests
|
||||
- Ensure join request approve/reject is actor-derived and permission-checked.
|
||||
- Provide endpoints for roster listing, role changes, and member removal.
|
||||
- Build roster admin UI.
|
||||
|
||||
Acceptance criteria:
|
||||
- Admin can manage roster end-to-end without passing performer IDs.
|
||||
|
||||
### Phase 4 — Results import and standings
|
||||
- Implement results import endpoints and recompute trigger behavior (manual + after import).
|
||||
- Make standings season-aware and include additional fields needed by the UI (or simplify UI to match real data).
|
||||
- Build results import UI and update standings UI accordingly.
|
||||
|
||||
Acceptance criteria:
|
||||
- Import updates standings deterministically and is visible in UI.
|
||||
|
||||
### Phase 5 — Stewarding performance + wallet cleanup
|
||||
- Replace N+1 stewarding fetch with aggregate endpoint; update UI to use it.
|
||||
- Wallet: admin-only gating; remove hardcoded season/account; hide or compute static sections.
|
||||
|
||||
Acceptance criteria:
|
||||
- Stewarding loads in bounded requests.
|
||||
- Wallet page does not contain hardcoded season/account IDs.
|
||||
|
||||
### Phase 6 — Quality gates
|
||||
- Run lint, typecheck, and tests until clean for all changes introduced.
|
||||
|
||||
---
|
||||
|
||||
## Key decisions / assumptions (explicit)
|
||||
- “Admin acquisition” MVP includes all three workflows and therefore requires solid permissions and contracts first.
|
||||
- Wallet is not a blocker for admin acquisition but must not mislead; keep demo-only unless you want it fully MVP.
|
||||
@@ -1,221 +0,0 @@
|
||||
# Media Streamlining Plan: Driver Avatars, Team Logos, League Logos
|
||||
|
||||
## Goal
|
||||
Create one clean, conflict-free way to represent and deliver:
|
||||
|
||||
- Driver avatars (defaults now; user uploads later)
|
||||
- Team logos (seeded)
|
||||
- League logos (seeded)
|
||||
|
||||
So that:
|
||||
|
||||
- Seeding never produces conflicting behavior across environments.
|
||||
- The UI never has to guess whether a value is a file path, an API route, a generated asset, or an uploaded asset.
|
||||
- There is exactly one place that decides the final image URL for each entity.
|
||||
|
||||
## What exists today (inventory, by responsibility)
|
||||
|
||||
### Driver avatars
|
||||
|
||||
Where they surface:
|
||||
|
||||
- Driver lists, driver leaderboards, race entry lists, dashboard summaries, and social/friend UI elements.
|
||||
- API payloads sometimes include an avatar URL; other times the client constructs a URL from the driver id.
|
||||
- There are multiple fallback strategies: empty string, null, or client-side default image.
|
||||
|
||||
Where they come from:
|
||||
|
||||
- A “default avatar set” of three files exists in the website public assets.
|
||||
- There is also a server route that can generate an avatar image for a driver id.
|
||||
- Some parts of the system treat driver avatar as a user-uploadable media setting.
|
||||
|
||||
Observed problems:
|
||||
|
||||
- Mixed meaning of the avatar field: sometimes it is an absolute URL, sometimes a relative path, sometimes a server route string.
|
||||
- Multiple fallbacks implemented in multiple places leads to inconsistent UI and hard-to-debug “missing image” bugs.
|
||||
- Multiple “demo/fake” avatar generators exist, creating divergent behavior between environments.
|
||||
|
||||
### Team logos
|
||||
|
||||
Where they surface:
|
||||
|
||||
- Team cards, team leaderboards, recruiting/featured team sections.
|
||||
- Sometimes the UI uses `logoUrl` from the API payload; other times it falls back to a server route based on id.
|
||||
|
||||
Where they come from:
|
||||
|
||||
- A server route can generate a team logo image for a team id.
|
||||
- Seed logic also “pre-seeds” team logos by writing route strings into an in-memory store.
|
||||
|
||||
Observed problems:
|
||||
|
||||
- Team “logoUrl” may be an actual URL, or it may be a placeholder, or it may be a server route string stored as data.
|
||||
- Storing route strings as if they were media values creates conflicts when routes change.
|
||||
- In some persistence modes the “seeded logo store” is not truly persisted, so bootstrapping may re-trigger reseeding or create inconsistent results.
|
||||
|
||||
### League logos
|
||||
|
||||
Where they surface:
|
||||
|
||||
- League cards, league headers, league pages.
|
||||
- UI tends to call a client-side helper that builds a league-logo URL from id.
|
||||
|
||||
Where they come from:
|
||||
|
||||
- A server route can generate a league logo image for a league id.
|
||||
- Seed logic also “pre-seeds” league logos by writing route strings into an in-memory store.
|
||||
|
||||
Observed problems:
|
||||
|
||||
- Same class of conflicts as team logos.
|
||||
- There is no single authoritative rule for when a league has a “real” logo versus a generated one.
|
||||
|
||||
## Proposed streamlined model (single canonical representation)
|
||||
|
||||
### Canonical concept: Media Reference (not a URL)
|
||||
Instead of treating stored values as “final URLs”, define a single canonical *media reference* for each entity image.
|
||||
|
||||
Media reference types:
|
||||
|
||||
- **System default**: a fixed asset shipped with the website (driver defaults: male/female/neutral).
|
||||
- **Generated**: deterministically generated from an entity id and a seeded pseudo-random source (team/league logos).
|
||||
- **Uploaded**: a user-uploaded object managed by the media subsystem.
|
||||
- **None**: intentionally unset.
|
||||
|
||||
Key rule: **only one layer resolves media references into URLs**.
|
||||
|
||||
### URL resolution responsibilities
|
||||
|
||||
- **Backend** resolves *references* into *final URLs* for API payloads.
|
||||
- **Backend** also serves the image bytes for generated assets and uploaded assets.
|
||||
- **Frontend** treats received URLs as ready-to-render and does not invent additional fallbacks beyond a single last-resort placeholder.
|
||||
|
||||
## Seeding strategy (cleanest route)
|
||||
|
||||
### Teams and leagues: seeded via faker, but without storing URLs
|
||||
|
||||
Requirement: “seed team and league logos via faker”.
|
||||
|
||||
Clean approach:
|
||||
|
||||
- During seed, assign each team/league a **Generated** media reference.
|
||||
- The generator uses faker with a seed derived from the entity id to produce a deterministic “logo identity” (colors, initials, shapes, etc.).
|
||||
- The stored value is **only the reference** (type + seed key), not a route string and not a URL.
|
||||
- When the UI needs to show the logo, it either receives a resolved URL in the API payload or uses a single, standardized media URL builder.
|
||||
|
||||
Benefits:
|
||||
|
||||
- Deterministic results: same team id always yields the same logo.
|
||||
- No conflicts when URLs/routes change.
|
||||
- No need to persist binary files for seeded logos.
|
||||
|
||||
### Drivers: seeded from the 3 default avatar images
|
||||
|
||||
Requirement: “seed driver logos from these defaults” and later “normally these would be user uploads”.
|
||||
|
||||
Clean approach:
|
||||
|
||||
- During seed, assign each driver a **System default** media reference selecting one of:
|
||||
- male-default-avatar
|
||||
- female-default-avatar
|
||||
- neutral-default-avatar
|
||||
- Selection is deterministic (based on driver id) so reseeding does not change faces randomly.
|
||||
- Later, if a user uploads an avatar, the reference switches to **Uploaded** and overrides the default.
|
||||
|
||||
Benefits:
|
||||
|
||||
- No dependency on generated avatars for baseline.
|
||||
- No ambiguous meaning of the avatar field.
|
||||
|
||||
## Contract rules (what the UI can rely on)
|
||||
|
||||
### Field semantics
|
||||
|
||||
- Every API payload that includes a driver/team/league image should provide a **single resolved URL field** for that image.
|
||||
- Resolved URL is either:
|
||||
- a valid URL string the UI can render immediately, or
|
||||
- null (meaning: show a generic placeholder).
|
||||
- Never send empty strings.
|
||||
- Never send “sometimes relative file path, sometimes server route” style mixed values.
|
||||
|
||||
### Fallback rules
|
||||
|
||||
- The backend resolver must guarantee a valid URL whenever it can (system default or generated).
|
||||
- The frontend uses exactly one last-resort placeholder if it receives null.
|
||||
- No per-component bespoke fallbacks.
|
||||
|
||||
## Streamlining work items (what changes where)
|
||||
|
||||
### 1) Centralize media reference resolution
|
||||
|
||||
Create one “media resolver” concept used by:
|
||||
|
||||
- API payload assembly for all places that include avatars/logos.
|
||||
- Image-serving routes for generated assets and uploaded assets.
|
||||
|
||||
This resolver is the only place that knows:
|
||||
|
||||
- how to map media references to a concrete image URL
|
||||
- what the fallback is when no uploaded media exists
|
||||
|
||||
### 2) Stop storing server route strings as data
|
||||
|
||||
Remove the pattern where seed logic writes values like “/api/media/.../logo” into an in-memory media store.
|
||||
|
||||
Replace it with:
|
||||
|
||||
- stored media references (generated/system-default/uploaded)
|
||||
- consistent URL resolution at response time
|
||||
|
||||
### 3) Normalize route prefixes and caching behavior
|
||||
|
||||
- Choose one public URL shape for these images and apply it universally.
|
||||
- Add consistent cache headers for generated assets (deterministic) so the browser and CDN can cache safely.
|
||||
|
||||
### 4) Align frontend consumption
|
||||
|
||||
- Ensure the UI always prefers the resolved URL from the API payload.
|
||||
- Where the UI only has an id (e.g. very lightweight list items), use a single shared “URL builder” instead of ad-hoc string concatenation.
|
||||
- Remove duplicate “if missing then fallback to …” logic sprinkled across components.
|
||||
|
||||
### 5) Align tests and demo fakes
|
||||
|
||||
- Eliminate competing fake avatar/logo generators.
|
||||
- Ensure all test fixtures use the same deterministic rules as seed and runtime generation.
|
||||
- Ensure snapshot/contract tests treat empty string as invalid and expect null instead.
|
||||
|
||||
### 6) Make bootstrapping/reseeding conflict-proof
|
||||
|
||||
- Reseed decision should be based on durable data correctness (presence of required entities) rather than transient “in-memory media store” state.
|
||||
- Ensure “missing avatar/logo” checks are aligned with the new media reference model.
|
||||
|
||||
### 7) Migration and cleanup
|
||||
|
||||
- Define how existing seeded databases are handled:
|
||||
- either a one-time cleanup that rewrites old stored values into the new reference model, or
|
||||
- a documented wipe-and-reseed path for local/dev environments.
|
||||
- Ensure the migration path eliminates stored route strings.
|
||||
|
||||
## Mermaid: Target flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UI[Website UI] --> API[API payloads include resolved image URLs]
|
||||
API --> RES[Media resolver]
|
||||
RES --> UP[Uploaded media storage]
|
||||
RES --> GEN[Deterministic generator]
|
||||
RES --> DEF[System default assets]
|
||||
GEN --> IMG[Image response with cache headers]
|
||||
UP --> IMG
|
||||
DEF --> UI
|
||||
IMG --> UI
|
||||
```
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Driver avatars always render with one of the three defaults unless an upload exists.
|
||||
- Team and league logos always render deterministically in dev/test seed, without persisting URLs.
|
||||
- No API payload returns empty string for avatar/logo.
|
||||
- No UI component constructs its own bespoke fallback logic.
|
||||
- No bootstrapping loop caused by “missing media” when media is generated or defaults are available.
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
# Media Seeding Plan for Team Logos and Driver Avatars
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan addresses the robust seeding of media assets (driver avatars and team logos) in the GridPilot development environment. The solution leverages existing static files for avatars and provides a reliable, Docker-compatible approach for team logos using Next.js API routes that serve SVG placeholders.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### What Exists
|
||||
1. **Static Avatar Files**: Three default avatars exist in `apps/website/public/images/avatars/`:
|
||||
- `male-default-avatar.jpg`
|
||||
- `female-default-avatar.jpeg`
|
||||
- `neutral-default-avatar.jpeg`
|
||||
|
||||
2. **Next.js Image Configuration**: `apps/website/next.config.mjs` is configured with:
|
||||
- Remote patterns for `localhost:3001` (API)
|
||||
- Remote patterns for `api:3000` (Docker API)
|
||||
- SVG support enabled
|
||||
- Image optimization disabled in development
|
||||
|
||||
3. **Media Controller**: `apps/api/src/domain/media/MediaController.ts` already generates SVG placeholders for:
|
||||
- Team logos (`/api/media/teams/:teamId/logo`)
|
||||
- Driver avatars (`/api/media/drivers/:driverId/avatar`)
|
||||
- League logos, covers, track images, etc.
|
||||
|
||||
4. **Current Seeding Logic**: `SeedRacingData.ts` calls `seedMediaAssets()` which sets URLs in the media repository.
|
||||
|
||||
### Problems Identified
|
||||
1. **Driver Avatars**: Current seeding uses `/api/media/avatar/:driverId` which generates SVG placeholders, not static files
|
||||
2. **Team Logos**: Current seeding uses `/api/media/teams/:teamId/logo` which generates SVG placeholders
|
||||
3. **InMemoryMediaRepository**: Stores URLs but doesn't provide actual file serving
|
||||
4. **No Static File Integration**: The existing static avatars aren't being used in seeding
|
||||
|
||||
## Solution Design
|
||||
|
||||
### 1. Driver Avatars Strategy (Static Files)
|
||||
|
||||
**Goal**: Use existing static avatar files for all seeded drivers.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
#### A. Enhanced Driver Seeding Logic
|
||||
```typescript
|
||||
// In adapters/bootstrap/SeedRacingData.ts - seedMediaAssets() method
|
||||
private async seedMediaAssets(seed: any): Promise<void> {
|
||||
const baseUrl = this.getMediaBaseUrl();
|
||||
|
||||
// Seed driver avatars using static files
|
||||
for (const driver of seed.drivers) {
|
||||
const avatarUrl = this.getDriverAvatarUrl(driver.id);
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setDriverAvatar) {
|
||||
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of seeding
|
||||
}
|
||||
|
||||
private getDriverAvatarUrl(driverId: string): string {
|
||||
// Use deterministic selection based on driver ID
|
||||
const numericSuffixMatch = driverId.match(/(\d+)$/);
|
||||
let useFemale = false;
|
||||
|
||||
if (numericSuffixMatch) {
|
||||
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
|
||||
useFemale = numericSuffix % 2 === 0;
|
||||
} else {
|
||||
// Fallback hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < driverId.length; i++) {
|
||||
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
|
||||
}
|
||||
useFemale = Math.abs(hash) % 2 === 0;
|
||||
}
|
||||
|
||||
// Return static file paths that Next.js can serve
|
||||
if (useFemale) {
|
||||
return '/images/avatars/female-default-avatar.jpeg';
|
||||
} else {
|
||||
return '/images/avatars/male-default-avatar.jpg';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Next.js Static File Serving
|
||||
The existing Next.js configuration already supports serving static files from `public/images/avatars/`. These URLs will work directly from the website container.
|
||||
|
||||
### 2. Team Logos Strategy (SVG Generation)
|
||||
|
||||
**Goal**: Provide reliable, Docker-compatible team logos that work without external dependencies.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
#### A. Enhanced Team Seeding Logic
|
||||
```typescript
|
||||
// In adapters/bootstrap/SeedRacingData.ts - seedMediaAssets() method
|
||||
// Seed team logos
|
||||
for (const team of seed.teams) {
|
||||
const logoUrl = `${baseUrl}/api/media/teams/${team.id}/logo`;
|
||||
|
||||
const mediaRepo = this.seedDeps.mediaRepository as any;
|
||||
if (mediaRepo.setTeamLogo) {
|
||||
mediaRepo.setTeamLogo(team.id, logoUrl);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. API Route Enhancement
|
||||
The existing `MediaController.ts` already provides `/api/media/teams/:teamId/logo` which generates SVG placeholders. This is perfect for Docker development because:
|
||||
- No external network dependencies
|
||||
- Deterministic generation based on team ID
|
||||
- Works in Docker containers
|
||||
- Served via the API container (port 3001)
|
||||
|
||||
#### C. Next.js Rewrites Configuration
|
||||
The existing `next.config.mjs` already has rewrites that route `/api/*` to the API container:
|
||||
```javascript
|
||||
async rewrites() {
|
||||
const baseUrl = 'http://api:3000';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${baseUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- Website requests `/api/media/teams/team-1/logo` → Next.js rewrites to `http://api:3000/api/media/teams/team-1/logo`
|
||||
- API serves SVG placeholder
|
||||
- Next.js Image component can optimize/cache it
|
||||
|
||||
### 3. Architecture Flow
|
||||
|
||||
```
|
||||
Seeding Phase:
|
||||
1. SeedRacingData.execute() creates drivers/teams
|
||||
2. seedMediaAssets() calculates URLs
|
||||
3. InMemoryMediaRepository stores: driverId → avatarUrl, teamId → logoUrl
|
||||
4. URLs are stored in database entities
|
||||
|
||||
Runtime Phase (Website):
|
||||
1. Component requests driver avatar: `/images/avatars/male-default-avatar.jpg`
|
||||
2. Next.js serves static file directly from public directory
|
||||
|
||||
Runtime Phase (Team Logo):
|
||||
1. Component requests team logo: `/api/media/teams/team-1/logo`
|
||||
2. Next.js rewrite: → `http://api:3000/api/media/teams/team-1/logo`
|
||||
3. API generates SVG and returns
|
||||
4. Next.js Image component optimizes/caches
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update SeedRacingData.ts
|
||||
Modify the `seedMediaAssets()` method to use static files for drivers and existing API routes for teams.
|
||||
|
||||
### Step 2: Update InMemoryMediaRepository
|
||||
Ensure it has methods for storing/retrieving media URLs:
|
||||
```typescript
|
||||
setDriverAvatar(driverId: string, url: string): void;
|
||||
setTeamLogo(teamId: string, url: string): void;
|
||||
getDriverAvatar(driverId: string): Promise<string | null>;
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
```
|
||||
|
||||
### Step 3: Verify Next.js Configuration
|
||||
Ensure `next.config.mjs` has:
|
||||
- Proper remote patterns for localhost and Docker API
|
||||
- SVG support enabled
|
||||
- Image optimization disabled in dev
|
||||
|
||||
### Step 4: Test the Flow
|
||||
1. Start Docker development environment
|
||||
2. Trigger database seeding
|
||||
3. Verify driver avatars point to static files
|
||||
4. Verify team logos point to API routes
|
||||
5. Test that URLs resolve correctly in website
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
### Reliability
|
||||
- **No external dependencies**: No network calls to external services
|
||||
- **Docker-compatible**: Works entirely within Docker network
|
||||
- **Deterministic**: Same IDs always produce same URLs
|
||||
|
||||
### Performance
|
||||
- **Fast**: Static files served directly, SVG generated on-demand
|
||||
- **Cached**: Next.js can cache API responses
|
||||
- **No cold starts**: No external service initialization needed
|
||||
|
||||
### Maintainability
|
||||
- **Clean architecture**: Follows existing patterns
|
||||
- **Testable**: Easy to verify URLs are correct
|
||||
- **Extensible**: Can add more media types easily
|
||||
|
||||
### Developer Experience
|
||||
- **Simple**: No API keys or external services to configure
|
||||
- **Fast**: No waiting for external API responses
|
||||
- **Offline-capable**: Works without internet connection
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Risk: Static files not accessible in Docker
|
||||
**Mitigation**: Files are in `apps/website/public/images/avatars/` which is mounted via volumes in docker-compose.dev.yml
|
||||
|
||||
### Risk: API routes not working in Docker
|
||||
**Mitigation**: Next.js rewrites already route `/api/*` to `http://api:3000` which is the Docker API service
|
||||
|
||||
### Risk: Image optimization fails
|
||||
**Mitigation**: Set `unoptimized: true` in development, which is already configured
|
||||
|
||||
### Risk: URLs don't match between seeding and runtime
|
||||
**Mitigation**: Use consistent URL generation logic in both seeding and display components
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Verify `getDriverAvatarUrl()` returns correct static file paths
|
||||
- Verify `seedMediaAssets()` calls repository methods correctly
|
||||
|
||||
### Integration Tests
|
||||
- Verify seeding creates correct URLs in database
|
||||
- Verify API routes return SVG for team logos
|
||||
- Verify static files are accessible via Next.js
|
||||
|
||||
### E2E Tests
|
||||
- Load dashboard page
|
||||
- Verify driver avatars display correctly
|
||||
- Verify team logos display correctly
|
||||
- Verify no console errors for missing images
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`adapters/bootstrap/SeedRacingData.ts`**
|
||||
- Update `seedMediaAssets()` method
|
||||
- Add `getDriverAvatarUrl()` helper
|
||||
|
||||
2. **`adapters/racing/persistence/media/InMemoryMediaRepository.ts`**
|
||||
- Ensure methods exist for avatar/logo storage
|
||||
|
||||
3. **`apps/website/next.config.mjs`**
|
||||
- Verify configuration is correct for Docker
|
||||
|
||||
4. **`docker-compose.dev.yml`**
|
||||
- Ensure volumes mount public directory
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ Every driver has an avatar URL pointing to static files
|
||||
✅ Every team has a logo URL pointing to API routes
|
||||
✅ URLs work in Docker development environment
|
||||
✅ No external network dependencies required
|
||||
✅ Images display correctly in website UI
|
||||
✅ Seeding is deterministic and reproducible
|
||||
|
||||
## Conclusion
|
||||
|
||||
This plan provides a robust, Docker-compatible solution for media seeding that leverages existing infrastructure:
|
||||
- **Driver avatars**: Static files for reliability and speed
|
||||
- **Team logos**: SVG generation for consistency and no external dependencies
|
||||
- **Architecture**: Follows clean architecture principles
|
||||
- **Docker support**: Works seamlessly in containerized development
|
||||
|
||||
The solution is simple, maintainable, and addresses all the constraints mentioned in the task.
|
||||
@@ -1,364 +0,0 @@
|
||||
# Media streamlining debug fix plan
|
||||
|
||||
Goal: make media rendering (avatars, team logos, league logos) deterministic, debuggable, and boring. Remove misleading stubs from runtime, converge on one URL shape (`/media/...`) end-to-end, and add observability so broken images can be diagnosed in minutes.
|
||||
|
||||
Non-goals:
|
||||
- No CDN rollout (we still design for it).
|
||||
- No “AI generation” pipeline. Keep existing deterministic SVG generation in [`MediaGenerationService`](core/media/domain/services/MediaGenerationService.ts:9).
|
||||
|
||||
## 1) Current state (facts from code)
|
||||
|
||||
### Backend (API)
|
||||
|
||||
- The canonical HTTP routes exist in [`MediaController`](apps/api/src/domain/media/MediaController.ts:25):
|
||||
- Team logo: `GET /media/teams/:teamId/logo` (SVG) [`getTeamLogo()`](apps/api/src/domain/media/MediaController.ts:72)
|
||||
- League logo: `GET /media/leagues/:leagueId/logo` (SVG) [`getLeagueLogo()`](apps/api/src/domain/media/MediaController.ts:83)
|
||||
- Driver avatar: `GET /media/avatar/:driverId` (SVG) [`getDriverAvatar()`](apps/api/src/domain/media/MediaController.ts:111)
|
||||
- Default: `GET /media/default/:variant` (PNG placeholder) [`getDefaultMedia()`](apps/api/src/domain/media/MediaController.ts:125)
|
||||
|
||||
- Seeding sets `logoRef` for teams/leagues to “generated” references:
|
||||
- Team: [`RacingTeamFactory.createTeams()`](adapters/bootstrap/racing/RacingTeamFactory.ts:26) sets [`MediaReference.generated()`](core/domain/media/MediaReference.ts:114) via line [`logoRef: MediaReference.generated('team', teamId)`](adapters/bootstrap/racing/RacingTeamFactory.ts:51)
|
||||
- League: [`RacingLeagueFactory.create()`](adapters/bootstrap/racing/RacingLeagueFactory.ts:14) sets [`logoRef: MediaReference.generated('league', leagueData.id)`](adapters/bootstrap/racing/RacingLeagueFactory.ts:403)
|
||||
|
||||
- Presenters resolve `MediaReference` → URL string via a `MediaResolverPort`:
|
||||
- Teams list: [`AllTeamsPresenter.present()`](apps/api/src/domain/team/presenters/AllTeamsPresenter.ts:25) resolves via [`this.mediaResolver.resolve()`](apps/api/src/domain/team/presenters/AllTeamsPresenter.ts:45)
|
||||
|
||||
### Frontend (Website)
|
||||
|
||||
- The landing page cards render with Next `Image`:
|
||||
- Team card: [`TeamCard`](apps/website/components/teams/TeamCard.tsx:67) uses [`<Image src={logoUrl}>`](apps/website/components/teams/TeamCard.tsx:101)
|
||||
|
||||
- Some UI code uses an internal URL builder that does not match the API’s route shapes:
|
||||
- [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) builds `/media/generated/team-logo/:id` etc.
|
||||
- Example usage: [`TeamLadderRow`](apps/website/components/teams/TeamLadderRow.tsx:18) uses [`getMediaUrl('team-logo', teamId)`](apps/website/components/teams/TeamLadderRow.tsx:29)
|
||||
|
||||
- Next.js image config currently allows localhost and allows SVG:
|
||||
- [`next.config.mjs`](apps/website/next.config.mjs:1) includes `remotePatterns` for `localhost:3001` and `dangerouslyAllowSVG: true`.
|
||||
|
||||
## 2) Suspected root causes (ranked)
|
||||
|
||||
### A. URL shape mismatch in Website fallback builder
|
||||
|
||||
The Website builder [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) generates paths like:
|
||||
|
||||
- `/media/generated/team-logo/:id`
|
||||
|
||||
But the API serves:
|
||||
|
||||
- `/media/teams/:id/logo` or `/media/generated/team/:id` (generic endpoint)
|
||||
|
||||
Result: 404s for any page that uses [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) instead of `logoUrl` returned by the API.
|
||||
|
||||
### B. Runtime accidentally uses the in-memory resolver (misleading)
|
||||
|
||||
In API Team DI, the runtime media resolver is currently the stub [`InMemoryMediaResolverAdapter`](adapters/media/MediaResolverInMemoryAdapter.ts:60) via [`TeamProviders`](apps/api/src/domain/team/TeamProviders.ts:28).
|
||||
|
||||
That adapter is explicitly described as “fake URLs” and has URL shapes that don’t match the API controller, e.g. system-default returns `${base}/default/${ref.variant}` in [`InMemoryMediaResolverAdapter.resolve()`](adapters/media/MediaResolverInMemoryAdapter.ts:80).
|
||||
|
||||
Even if team logos are “generated” and map to `/media/teams/:id/logo`, this is an architectural footgun:
|
||||
- It makes it easy for other entity presenters (drivers/leagues/etc.) to emit non-existent URLs.
|
||||
- It undermines confidence when debugging.
|
||||
|
||||
### C. Next.js `Image` error symptoms
|
||||
|
||||
You reported: Next.js `Image` errors about remote host not configured and or SVG blocked.
|
||||
|
||||
Given [`next.config.mjs`](apps/website/next.config.mjs:12) appears to allow `localhost:3001` and enables SVG, this suggests at least one of:
|
||||
- The actual `src` host differs (e.g. `127.0.0.1`, `api:3000`, or another hostname).
|
||||
- The `src` is not a valid URL string at runtime (empty string, malformed).
|
||||
- A stale container is running with older config.
|
||||
|
||||
The plan below makes `src` always same-origin to the Website (relative `/media/...`), eliminating this entire class of errors.
|
||||
|
||||
## 3) Target architecture (strict, minimal, easy-to-reason)
|
||||
|
||||
### 3.1 Invariants (rules)
|
||||
|
||||
1) Canonical media URLs are always *paths* starting with `/media/`.
|
||||
2) API DTO fields like `team.logoUrl` are either:
|
||||
- `null`, or
|
||||
- a path `/media/...` (never absolute URLs, never empty string).
|
||||
3) The Website renders media using *only*:
|
||||
- DTO-provided `/media/...` URLs, or
|
||||
- a single shared Website builder that produces `/media/...` URLs matching the API routes.
|
||||
4) The Website never needs to know `http://localhost:3001`.
|
||||
5) All runtime resolution uses exactly one resolver implementation (no stubs).
|
||||
|
||||
### 3.2 One canonical path schema
|
||||
|
||||
Canonical HTTP paths (served by API, fetched by browser via Website proxy rewrite):
|
||||
|
||||
- Team logo SVG: `/media/teams/{teamId}/logo`
|
||||
- League logo SVG: `/media/leagues/{leagueId}/logo`
|
||||
- Driver avatar SVG: `/media/avatar/{driverId}`
|
||||
- Defaults (PNG): `/media/default/{variant}`
|
||||
- Uploaded: `/media/uploaded/{mediaId}`
|
||||
|
||||
`/media/generated/:type/:id` can remain, but should become an internal alias only (not returned by resolvers/presenters).
|
||||
|
||||
### 3.3 Single resolver for the whole API
|
||||
|
||||
- Runtime resolver: [`MediaResolverAdapter`](adapters/media/MediaResolverAdapter.ts:53) using the concrete sub-resolvers:
|
||||
- [`DefaultMediaResolverAdapter`](adapters/media/resolvers/DefaultMediaResolverAdapter.ts:34)
|
||||
- [`GeneratedMediaResolverAdapter`](adapters/media/resolvers/GeneratedMediaResolverAdapter.ts:35)
|
||||
- [`UploadedMediaResolverAdapter`](adapters/media/resolvers/UploadedMediaResolverAdapter.ts:37)
|
||||
|
||||
Resolver output must be *path-only*:
|
||||
- For any `MediaReference`, `resolve()` returns `/media/...` or `null`.
|
||||
- No `baseUrl` parameter is needed for DTOs.
|
||||
|
||||
Rationale: once URLs are path-only, the Website can proxy them and Next `Image` becomes deterministic.
|
||||
|
||||
### 3.4 Proper storage abstraction (core port) + adapter implementation
|
||||
|
||||
This is required to align with Clean Architecture rules in [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1) and avoid runtime stubs.
|
||||
|
||||
#### 3.4.1 Core (ports + use-cases)
|
||||
|
||||
We already have a core port [`MediaStoragePort`](apps/api/src/domain/media/MediaProviders.ts:9) used by the media use-cases (upload/delete). The plan is to make it real and remove mock usage in runtime.
|
||||
|
||||
Target responsibilities:
|
||||
|
||||
- Core Application port (interface): `MediaStoragePort`
|
||||
- `uploadMedia(file, metadata) -> { success, url?, filename?, storageKey?, contentType? }`
|
||||
- `deleteMedia(storageKey) -> void`
|
||||
- (optional but recommended) `getReadStream(storageKey) -> stream` or `getBytes(storageKey) -> Buffer`
|
||||
|
||||
- Core Domain entity (or value object): `Media` should reference a storage identifier (e.g. `storageKey`) and `contentType`.
|
||||
- The domain does not store absolute URLs.
|
||||
- The resolver + controller decide how a `storageKey` becomes `/media/uploaded/{id}`.
|
||||
|
||||
#### 3.4.2 Adapters (file storage)
|
||||
|
||||
Add a concrete adapter: `FileSystemMediaStorageAdapter` under `adapters/`.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Store files under a single base directory (configured via env):
|
||||
- `GRIDPILOT_MEDIA_STORAGE_DIR=/data/media` (container path)
|
||||
- Use deterministic, collision-resistant keys:
|
||||
- `uploaded/{mediaId}/{originalFilename}` or `uploaded/{mediaId}` (single-file per mediaId)
|
||||
- Enforce content-type allowlist for images (at minimum `image/png`, `image/jpeg`, `image/svg+xml`).
|
||||
- Never return public absolute URLs from the adapter. Return `storageKey` only.
|
||||
|
||||
Docker alignment:
|
||||
|
||||
- Add a named volume mounted into `api` container for persisted dev media.
|
||||
|
||||
#### 3.4.3 API serving route for uploaded media
|
||||
|
||||
The API endpoint [`GET /media/uploaded/:mediaId`](apps/api/src/domain/media/MediaController.ts:169) is currently a stub.
|
||||
|
||||
Target:
|
||||
|
||||
- Look up `Media` by `mediaId` in `IMediaRepository`.
|
||||
- Read bytes/stream from `MediaStoragePort` using `storageKey`.
|
||||
- Set headers:
|
||||
- `Content-Type: <stored contentType>`
|
||||
- `Cache-Control: public, max-age=31536000, immutable` (if content-addressed) OR `max-age=3600` (if mutable)
|
||||
- Return 404 if missing.
|
||||
|
||||
This makes “uploaded” a first-class, debuggable path in the same `/media/...` scheme.
|
||||
|
||||
## 4) End-to-end trace (pseudocode)
|
||||
|
||||
This is the required mental model for debugging.
|
||||
|
||||
### 4.1 Seed → DB
|
||||
|
||||
```text
|
||||
teamId = seedId(team-1)
|
||||
team.logoRef = MediaReference.generated(team, teamId)
|
||||
persist team.logoRef as JSON
|
||||
```
|
||||
|
||||
### 4.2 API Use Case → Presenter → DTO
|
||||
|
||||
```text
|
||||
usecase GetAllTeamsUseCase
|
||||
loads Team entities
|
||||
returns { teams: [{ id, name, logoRef, logoUrl: null, ... }] }
|
||||
|
||||
presenter AllTeamsPresenter
|
||||
for each team:
|
||||
ref = MediaReference.fromJSON(team.logoRef)
|
||||
dto.logoUrl = MediaResolver.resolve(ref)
|
||||
=> /media/teams/{teamId}/logo
|
||||
response JSON contains logoUrl string or null
|
||||
```
|
||||
|
||||
### 4.3 Website → React component → img src
|
||||
|
||||
```text
|
||||
LandingService.getHomeDiscovery
|
||||
calls GET {apiBaseUrl}/teams/all
|
||||
creates TeamCardViewModel with dto.logoUrl
|
||||
|
||||
TeamCard
|
||||
Image src = team.logoUrl
|
||||
(src is relative /media/...)
|
||||
```
|
||||
|
||||
### 4.4 Browser fetch → Website rewrite → API bytes
|
||||
|
||||
```text
|
||||
browser GET http://localhost:3000/media/teams/{id}/logo
|
||||
Next rewrite proxies to http://api:3000/media/teams/{id}/logo
|
||||
API returns image/svg+xml bytes
|
||||
browser renders
|
||||
```
|
||||
|
||||
## 5) Debuggability improvements (must-have)
|
||||
|
||||
### 5.1 Add a debug resolve endpoint in API
|
||||
|
||||
Add `GET /media/debug/resolve` in [`MediaController`](apps/api/src/domain/media/MediaController.ts:25).
|
||||
|
||||
Input options:
|
||||
- Query param `ref` as base64url JSON of `MediaReferenceProps`.
|
||||
- Or explicit query params: `type`, `variant`, `avatarVariant`, `generationRequestId`, `mediaId`.
|
||||
|
||||
Output JSON:
|
||||
- `ref`: the parsed ref (as JSON)
|
||||
- `refHash`: same as [`MediaReference.hash()`](core/domain/media/MediaReference.ts:271)
|
||||
- `resolvedPath`: `/media/...` or null
|
||||
- `resolver`: which branch handled it (default or generated or uploaded or none)
|
||||
- `notes`: validation warnings (e.g. generationRequestId format)
|
||||
|
||||
This endpoint exists to debug resolvers without hitting entity APIs.
|
||||
|
||||
### 5.2 Structured logs
|
||||
|
||||
Add structured logs on each media request:
|
||||
|
||||
- In [`MediaController.getTeamLogo()`](apps/api/src/domain/media/MediaController.ts:72) and similar endpoints:
|
||||
- log: route, entityId, cache-control chosen
|
||||
- log: svg length, deterministic seed used
|
||||
|
||||
- In resolver:
|
||||
- log: `refHash`, resolved path, branch
|
||||
|
||||
### 5.3 Curl recipes (copy/paste)
|
||||
|
||||
Teams API returning logoUrl:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3001/teams/all | jq '.teams[0] | {id, name, logoUrl}'
|
||||
```
|
||||
|
||||
Team logo bytes:
|
||||
|
||||
```bash
|
||||
TEAM_ID=$(curl -sS http://localhost:3001/teams/all | jq -r '.teams[0].id')
|
||||
curl -i http://localhost:3001/media/teams/$TEAM_ID/logo | sed -n '1,20p'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `HTTP/1.1 200 OK`
|
||||
- `content-type: image/svg+xml`
|
||||
|
||||
Website proxy path (after rewrite is added):
|
||||
|
||||
```bash
|
||||
curl -i http://localhost:3000/media/teams/$TEAM_ID/logo | sed -n '1,20p'
|
||||
```
|
||||
|
||||
## 6) Concrete fixes (file-by-file)
|
||||
|
||||
### 6.1 Remove misleading runtime stubs
|
||||
|
||||
1) Stop using [`InMemoryMediaResolverAdapter`](adapters/media/MediaResolverInMemoryAdapter.ts:60) in API runtime providers.
|
||||
- Replace in [`TeamProviders`](apps/api/src/domain/team/TeamProviders.ts:28) (and similar providers in drivers/leagues if present) with the real [`MediaResolverAdapter`](adapters/media/MediaResolverAdapter.ts:53).
|
||||
|
||||
2) Ensure any “in-memory” resolver remains test-only:
|
||||
- Keep it referenced only in unit tests, not in app modules/providers.
|
||||
|
||||
### 6.2 Make resolver output path-only
|
||||
|
||||
Update [`MediaResolverAdapter.resolve()`](adapters/media/MediaResolverAdapter.ts:81) and sub-resolvers to return `/media/...` paths:
|
||||
|
||||
- [`DefaultMediaResolverAdapter.resolve()`](adapters/media/resolvers/DefaultMediaResolverAdapter.ts:44): `/media/default/...`
|
||||
- [`GeneratedMediaResolverAdapter.resolve()`](adapters/media/resolvers/GeneratedMediaResolverAdapter.ts:45):
|
||||
- team → `/media/teams/{id}/logo`
|
||||
- league → `/media/leagues/{id}/logo`
|
||||
- driver → `/media/avatar/{id}`
|
||||
- [`UploadedMediaResolverAdapter.resolve()`](adapters/media/resolvers/UploadedMediaResolverAdapter.ts:47): `/media/uploaded/{mediaId}`
|
||||
|
||||
Remove all “baseUrl” joining logic from resolvers.
|
||||
|
||||
### 6.3 Website must stop inventing wrong media URLs
|
||||
|
||||
1) Replace or delete [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11).
|
||||
- Either remove it entirely, or redefine it to output canonical `/media/...` paths.
|
||||
|
||||
2) Update all call sites found via:
|
||||
- [`TeamLadderRow`](apps/website/components/teams/TeamLadderRow.tsx:18)
|
||||
- [`LeagueHeader`](apps/website/components/leagues/LeagueHeader.tsx:1)
|
||||
- [`FriendPill`](apps/website/components/social/FriendPill.tsx:1)
|
||||
- [`apps/website/app/teams/[id]/page.tsx`](apps/website/app/teams/[id]/page.tsx:195)
|
||||
- [`apps/website/app/profile/page.tsx`](apps/website/app/profile/page.tsx:409)
|
||||
|
||||
to use either:
|
||||
- DTO-provided URLs, or
|
||||
- a single canonical builder aligned with API routes.
|
||||
|
||||
### 6.4 Add Website rewrite for `/media/*`
|
||||
|
||||
Extend [`next.config.mjs rewrites()`](apps/website/next.config.mjs:47) to also proxy `/media/:path*` to `http://api:3000/media/:path*` in dev.
|
||||
|
||||
This yields same-origin image URLs for the browser:
|
||||
- `src=/media/...` always.
|
||||
|
||||
### 6.5 Tests
|
||||
|
||||
1) Unit tests for resolver mapping:
|
||||
- Add tests around [`GeneratedMediaResolverAdapter.resolve()`](adapters/media/resolvers/GeneratedMediaResolverAdapter.ts:45) to ensure `team-<id>` → `/media/teams/<id>/logo`.
|
||||
|
||||
2) API presenter contract test:
|
||||
- Verify `logoUrl` is `null` or starts with `/media/` in [`AllTeamsPresenter`](apps/api/src/domain/team/presenters/AllTeamsPresenter.ts:8).
|
||||
|
||||
3) E2E Playwright image smoke:
|
||||
- Add a test that loads the landing page, finds at least one team logo `<img>`, and asserts the image request returns 200.
|
||||
- Use existing Playwright config files like [`playwright.website.config.ts`](playwright.website.config.ts:1).
|
||||
|
||||
4) Media upload + serve integration test:
|
||||
|
||||
- Upload an image via `POST /media/upload`.
|
||||
- Verify response includes a `mediaId` and DTO uses `/media/uploaded/{mediaId}` (path-only rule).
|
||||
- Fetch `/media/uploaded/{mediaId}` and assert status 200 + correct `Content-Type`.
|
||||
|
||||
## 7) Mermaid flow (new architecture)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Bootstrap seed sets MediaReference] --> B[DB stores logoRef JSON]
|
||||
B --> C[API use case returns logoRef]
|
||||
C --> D[Presenter resolves ref to media path]
|
||||
D --> E[DTO logoUrl is slash media path]
|
||||
E --> F[Website renders Image src slash media path]
|
||||
F --> G[Next rewrite proxies to API media route]
|
||||
G --> H[MediaController returns SVG or PNG bytes]
|
||||
```
|
||||
|
||||
## 8) TDD execution order (implementation guidance)
|
||||
|
||||
1) Add unit tests for canonical resolver mapping (generated/system-default/uploaded).
|
||||
2) Change resolver implementations to return path-only and make tests pass.
|
||||
3) Update API providers to use real resolver everywhere (remove runtime usage of in-memory resolver).
|
||||
4) Add `/media/:path*` rewrite in Website.
|
||||
5) Replace Website `getMediaUrl` and all call sites.
|
||||
6) Add API debug endpoint and structured logs.
|
||||
7) Replace mock `MediaStoragePort` with real filesystem adapter, wire env + volume.
|
||||
8) Implement uploaded media serving endpoint (remove stub), add integration test.
|
||||
9) Add Playwright test verifying image loads.
|
||||
|
||||
## 9) Acceptance criteria
|
||||
|
||||
1) `GET http://localhost:3001/teams/all` returns `logoUrl` values that are either `null` or begin with `/media/`.
|
||||
2) `GET http://localhost:3000/media/teams/{id}/logo` returns 200 with `image/svg+xml`.
|
||||
3) No Next `Image` remote-host/SVG errors in dev for logos.
|
||||
4) Playwright test passes: at least one image request returns 200 on a real page.
|
||||
|
||||
5) Upload flow works end-to-end:
|
||||
- `POST /media/upload` stores a file via filesystem adapter.
|
||||
- `GET /media/uploaded/{mediaId}` returns the stored bytes with correct headers.
|
||||
@@ -1,415 +0,0 @@
|
||||
# Next.js RSC + Client ViewModels + Display Objects (STRICT)
|
||||
|
||||
This document is FINAL and STRICT. No alternative interpretations.
|
||||
|
||||
## 1) System boundary (non-negotiable)
|
||||
|
||||
1. `apps/api` is the single source of truth for:
|
||||
|
||||
- business rules
|
||||
- validation
|
||||
- authorization decisions
|
||||
- canonical filtering and canonical sorting
|
||||
|
||||
2. `apps/website` is presentation infrastructure:
|
||||
|
||||
- renders UI using Next.js App Router
|
||||
- consumes `apps/api` via existing clients/services
|
||||
- performs routing/session/caching/composition
|
||||
- MUST NOT replicate business truth
|
||||
|
||||
## 2) Layering rules
|
||||
|
||||
### 2.1 Server route entry modules are composition-only
|
||||
|
||||
All `page.tsx` modules under [apps/website/app](apps/website/app/page.tsx:1) are composition-only.
|
||||
|
||||
`page.tsx` modules MAY:
|
||||
|
||||
- read `params` / `searchParams`
|
||||
- call [`redirect()`](apps/website/app/leaderboards/page.tsx:7) or [`notFound()`](apps/website/app/dashboard/page.tsx:1)
|
||||
- call a server-side query class
|
||||
- render server and client components
|
||||
|
||||
`page.tsx` modules MUST NOT:
|
||||
|
||||
- instantiate ViewModels (example forbidden: [`new DriverProfileViewModel()`](apps/website/lib/view-models/DriverProfileViewModel.ts:108))
|
||||
- implement formatting (dates, localization, percent, currency)
|
||||
- implement filtering/sorting (canonical or view-only)
|
||||
- map API payloads into UI-specific shapes
|
||||
- define reusable helper functions
|
||||
|
||||
### 2.2 Website server query classes (presentation queries)
|
||||
|
||||
Each route MUST have exactly one server query class:
|
||||
|
||||
- `apps/website/lib/page-queries/<RouteName>PageQuery.ts`
|
||||
|
||||
The query class MUST:
|
||||
|
||||
- call services that call `apps/api` (example current service: [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:10))
|
||||
- return a Page DTO (defined below)
|
||||
- contain no formatting/filtering/sorting
|
||||
|
||||
The query class MUST NOT:
|
||||
|
||||
- contain business rules
|
||||
- contain canonical ordering decisions
|
||||
|
||||
If ordering/filtering is needed, it MUST be implemented in `apps/api`.
|
||||
|
||||
### 2.3 Client ViewModels
|
||||
|
||||
ViewModels live in [apps/website/lib/view-models](apps/website/lib/view-models/DriverProfileViewModel.ts:1).
|
||||
|
||||
ViewModels MUST:
|
||||
|
||||
- be instantiated only in client modules (`'use client'`)
|
||||
- accept DTOs only (plain data)
|
||||
- expose view-only derived values (never business truth)
|
||||
|
||||
ViewModels MUST NOT be passed into Templates.
|
||||
|
||||
ViewModels SHOULD be the primary place that *composes* Display Objects.
|
||||
|
||||
### 2.4 Display Objects
|
||||
|
||||
Display Objects follow [docs/architecture/DISPLAY_OBJECTS.md](docs/architecture/DISPLAY_OBJECTS.md:1).
|
||||
|
||||
Display Objects MUST:
|
||||
|
||||
- live under `apps/website/lib/display-objects/*` (example existing: [apps/website/lib/display-objects/LeagueRoleDisplay.ts](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1))
|
||||
- be deterministic and side-effect free
|
||||
- be the ONLY place where formatting/mapping conventions exist
|
||||
|
||||
Pages MUST NOT format. Templates MUST NOT format.
|
||||
|
||||
Display Objects lifecycle (strict):
|
||||
|
||||
- Display Objects are created in client code.
|
||||
- Display Objects are typically created by ViewModels (recommended), and their primitive outputs are used to build ViewData.
|
||||
- Display Object instances MUST NOT cross any serialization boundary (RSC boundary, network, storage).
|
||||
|
||||
#### 2.4.1 Display Objects are Frontend Value Objects (strict definition)
|
||||
|
||||
Treat Display Objects like Domain Value Objects, but for the **presentation layer**.
|
||||
|
||||
Display Objects are:
|
||||
|
||||
- **Class-based**
|
||||
- **Immutable**
|
||||
- **Small** (one concept per object)
|
||||
- **Deterministic** (same input -> same output everywhere)
|
||||
- **Side-effect free**
|
||||
|
||||
Display Objects are NOT:
|
||||
|
||||
- utility modules of exported functions
|
||||
- global lookup tables exported for ad hoc access
|
||||
- a place to hide page logic
|
||||
|
||||
Why strict class-based?
|
||||
|
||||
- Naming: an object name communicates the concept (example: `MoneyDisplay`, `CountryDisplay`, `MonthYearDisplay`)
|
||||
- Encapsulation: invariants and normalization live in one constructor path
|
||||
- Reuse: ViewModels can share them without duplicating logic
|
||||
|
||||
#### 2.4.2 Allowed responsibilities
|
||||
|
||||
Display Objects MAY:
|
||||
|
||||
- format raw values into **display strings** (date, number, money)
|
||||
- map codes -> labels/icons/style tokens
|
||||
- expose variants explicitly (for example `asShortLabel()`, `asLongLabel()`)
|
||||
|
||||
Display Objects MUST NOT:
|
||||
|
||||
- contain business rules (those live in `apps/api`)
|
||||
- validate domain invariants
|
||||
- call network or storage
|
||||
- depend on framework runtime (React, Next.js)
|
||||
- depend on runtime locale/timezone formatting APIs (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) “Deterministic formatting requirement for Display Objects”)
|
||||
|
||||
#### 2.4.3 Strict API shape
|
||||
|
||||
Each Display Object class MUST:
|
||||
|
||||
- have a single responsibility
|
||||
- accept only primitives in its constructor (or static constructor)
|
||||
- expose only primitive outputs (strings/numbers/booleans)
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
- `private constructor(...)`
|
||||
- `static fromX(value: ...)` factory for normalization
|
||||
- instance methods like `toString()`, `label()`, `cssClassToken()`, `ariaLabel()`
|
||||
|
||||
Non-negotiable: no exported free functions as the primary API.
|
||||
|
||||
#### 2.4.4 Serialization boundary rule
|
||||
|
||||
Display Objects MUST NEVER appear in:
|
||||
|
||||
- Page DTOs crossing server -> client
|
||||
- ViewData passed into Templates
|
||||
|
||||
Only the Display Object’s primitive outputs may be copied into ViewData.
|
||||
|
||||
## 3) ViewData for Templates (strict)
|
||||
|
||||
Templates MUST render **ViewData**, not ViewModels.
|
||||
|
||||
Definitions:
|
||||
|
||||
- **Page DTO**: the serializable data returned by a server query and passed across the RSC boundary.
|
||||
- **ViewModel**: client-only object that encapsulates view-only derivations and composes Display Objects.
|
||||
- **ViewData**: a JSON-serializable, template-ready data structure that Templates render.
|
||||
|
||||
Rules:
|
||||
|
||||
1) ViewData MUST be JSON-serializable (same restrictions as Page DTO in [Section 3](plans/nextjs-rsc-viewmodels-concept.md:83)).
|
||||
2) ViewData MUST contain only values ready for display. Templates MUST NOT format.
|
||||
3) ViewData MUST be produced in client code:
|
||||
- Initial render: from Page DTO (SSR-safe)
|
||||
- Post-hydration: from ViewModel (client-only)
|
||||
4) Formatting implementation MUST live in Display Objects in `apps/website/lib/display-objects/*`.
|
||||
5) ViewData MUST NOT contain Display Object instances. ViewData contains only primitives (mostly strings) that were produced by Display Objects.
|
||||
|
||||
Rationale: Display Objects are classes/value objects and are not safe to serialize across the Next.js Client Component boundary. They are used as deterministic formatters/mappers, but only their primitive outputs may enter ViewData.
|
||||
|
||||
## 4) DTO boundary (RSC boundary)
|
||||
|
||||
### 4.1 Page DTO definition
|
||||
|
||||
The ONLY data that may cross from a server component into a client component is a Page DTO.
|
||||
|
||||
Page DTOs MUST:
|
||||
|
||||
- be JSON-serializable
|
||||
- contain only primitives, arrays, and plain objects
|
||||
- use ISO strings for timestamps
|
||||
- use `null` for missing values (no `undefined`)
|
||||
|
||||
Page DTOs MUST NOT contain:
|
||||
|
||||
- ViewModels
|
||||
- Display Objects
|
||||
- `Date`
|
||||
- `Map` / `Set`
|
||||
- functions
|
||||
|
||||
### 4.2 DTO types
|
||||
|
||||
When a ViewModel already defines its raw data type, that raw data type IS the Page DTO.
|
||||
|
||||
Example (profile): [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
|
||||
|
||||
Dashboard MUST define an equivalent `DashboardOverviewViewModelData` (or analogous) next to the dashboard ViewModel.
|
||||
|
||||
## 4.3 Deterministic formatting requirement for Display Objects
|
||||
|
||||
Because ViewData is rendered during SSR and re-rendered after hydration, any formatting used to produce ViewData MUST be deterministic across Node and the browser.
|
||||
|
||||
Therefore Display Objects MUST NOT use locale-dependent runtime formatting APIs, including:
|
||||
|
||||
- `Intl.*`
|
||||
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
|
||||
|
||||
This policy is strict and global for `apps/website`: `Intl.*` and `toLocale*` are forbidden everywhere in rendering codepaths (pages, templates, components, view models, display objects). If formatting is required, it MUST be implemented deterministically via explicit algorithms/lookup tables.
|
||||
|
||||
Display Objects MAY use:
|
||||
|
||||
- explicit lookup tables (example: month names)
|
||||
- numeric formatting implemented without locale APIs
|
||||
|
||||
This is the only way to guarantee identical SSR and client outputs.
|
||||
|
||||
## 4.4 ViewData and Display Objects (serialization rule)
|
||||
|
||||
Display Objects are classes/value objects. They are NOT guaranteed to be serializable.
|
||||
|
||||
Therefore:
|
||||
|
||||
- ViewData MUST NOT contain Display Object instances.
|
||||
- ViewData contains only primitives (usually strings) produced by Display Objects.
|
||||
|
||||
## 5) Query result contract (no `null`)
|
||||
|
||||
Rationale: returning `null` from server-side fetch orchestration conflates “not found”, “unauthorized/redirect”, and “unexpected error”. This makes route behavior ambiguous and encourages pages to implement policy via ad hoc checks.
|
||||
|
||||
Therefore, this concept forbids `null` as a query outcome.
|
||||
|
||||
### 5.1 Mandatory `PageQueryResult` discriminated union
|
||||
|
||||
Every server query class (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:42)) MUST return a discriminated union:
|
||||
|
||||
- `ok` with `{ dto: PageDTO }`
|
||||
- `notFound`
|
||||
- `redirect` with `{ to: string }`
|
||||
- `error` with `{ errorId: string }` (and logging done server-side)
|
||||
|
||||
Pages MUST switch on this result and decide:
|
||||
|
||||
- `notFound` -> [`notFound()`](apps/website/app/dashboard/page.tsx:1)
|
||||
- `redirect` -> [`redirect()`](apps/website/app/leaderboards/page.tsx:7)
|
||||
- `error` -> throw to Next.js error boundary or render route error boundary
|
||||
|
||||
### 5.2 `PageDataFetcher` usage rule
|
||||
|
||||
The current [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) and [`PageDataFetcher.fetchManual()`](apps/website/lib/page/PageDataFetcher.ts:36) return `null` on error.
|
||||
|
||||
In the new architecture:
|
||||
|
||||
- Server page modules MUST NOT consume `null`-returning APIs for route decisions.
|
||||
- Server query classes MUST wrap any usage of [`PageDataFetcher`](apps/website/lib/page/PageDataFetcher.ts:9) into `PageQueryResult` and MUST NOT leak `null` upward.
|
||||
|
||||
If `PageDataFetcher` is refactored later, its single-fetch methods MUST return a result type (similar to [`FetchResult`](apps/website/lib/page/PageDataFetcher.ts:3)) rather than `null`.
|
||||
|
||||
## 5.3 DI usage (strict)
|
||||
|
||||
This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1).
|
||||
|
||||
Rules:
|
||||
|
||||
1) `page.tsx` modules MUST NOT access the DI container directly (no [`ContainerManager.getInstance()`](apps/website/lib/di/container.ts:67)).
|
||||
2) Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests.
|
||||
3) Because [`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container.
|
||||
4) Client components MAY use DI via `ContainerProvider` + hooks like [`useInject`](apps/website/lib/di/hooks/useInject.ts:1).
|
||||
|
||||
Non-negotiable: no stateful service instances may be shared across requests via the singleton container.
|
||||
|
||||
## 6) Required component shape per route
|
||||
|
||||
Every route MUST be structured as:
|
||||
|
||||
1) `page.tsx` (Server Component)
|
||||
2) `*PageClient.tsx` (Client Component)
|
||||
3) `*Template.tsx` (pure stateless UI)
|
||||
|
||||
### 6.1 Server `page.tsx`
|
||||
|
||||
Server `page.tsx` MUST:
|
||||
|
||||
- call the route query class
|
||||
- pass only the Page DTO into the client component
|
||||
|
||||
Server `page.tsx` MUST NOT:
|
||||
|
||||
- import from `apps/website/lib/view-models/*`
|
||||
- instantiate ViewModels
|
||||
|
||||
### 6.2 Client `*PageClient.tsx`
|
||||
|
||||
Client `*PageClient.tsx` MUST:
|
||||
|
||||
- start with `'use client'`
|
||||
- accept the Page DTO as prop
|
||||
- render the Template with **ViewData**
|
||||
|
||||
Client `*PageClient.tsx` MUST implement a two-phase render:
|
||||
|
||||
1) Initial render (SSR-safe):
|
||||
- MUST NOT instantiate ViewModels
|
||||
- MUST create initial ViewData directly from Page DTO
|
||||
- MUST render Template with initial ViewData
|
||||
|
||||
2) Post-hydration (client-only):
|
||||
- MUST instantiate the ViewModel
|
||||
- MUST derive enhanced ViewData from the ViewModel (using Display Objects)
|
||||
- MUST re-render Template with enhanced ViewData
|
||||
|
||||
## 6.4 Initial SSR ViewData policy (non-optional)
|
||||
|
||||
Initial SSR ViewData MUST be **fully populated**, but only using deterministic formatting as defined in [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) under “Deterministic formatting requirement for Display Objects”.
|
||||
|
||||
This yields:
|
||||
|
||||
- SSR delivers meaningful content (no skeleton-only pages)
|
||||
- Hydration stays stable because the same deterministic Display Objects run on both SSR and client
|
||||
|
||||
### 6.3 `*Template.tsx` (pure UI)
|
||||
|
||||
Templates MUST:
|
||||
|
||||
- be pure and stateless
|
||||
- accept `ViewData` only
|
||||
- contain no formatting logic
|
||||
- contain no filtering/sorting logic
|
||||
|
||||
Templates MAY be imported by server or client modules.
|
||||
|
||||
Templates MUST NOT import:
|
||||
|
||||
- `apps/website/lib/view-models/*`
|
||||
- `apps/website/lib/display-objects/*`
|
||||
|
||||
## 7) Hydration safety (strict)
|
||||
|
||||
Hydration mismatch warnings are treated as build-breaking defects.
|
||||
|
||||
Forbidden in any `page.tsx` module under [apps/website/app](apps/website/app/page.tsx:1):
|
||||
|
||||
- [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430)
|
||||
- any other locale/timezone dependent formatting
|
||||
- any non-determinism (`Math.random`, `Date.now`) during render
|
||||
|
||||
All human-readable formatting MUST be done via Display Objects in the client.
|
||||
|
||||
Additionally forbidden anywhere Display Objects are executed to produce ViewData:
|
||||
|
||||
- `Intl.*`
|
||||
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
|
||||
|
||||
## 8) Guardrails (mandatory)
|
||||
|
||||
### 8.1 Boundary tests
|
||||
|
||||
Extend [apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts](apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts:1) with tests that FAIL when:
|
||||
|
||||
- any `apps/website/app/**/page.tsx` imports from `apps/website/lib/view-models/*`
|
||||
- any `apps/website/app/**/page.tsx` contains banned formatting calls (including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430))
|
||||
- any `apps/website/app/**/page.tsx` contains sorting/filtering logic (`sort`, `filter`, `reduce`) outside trivial null checks
|
||||
|
||||
Add template boundary tests that FAIL when:
|
||||
|
||||
- any `apps/website/templates/**` imports from `apps/website/lib/view-models/*`
|
||||
- any `apps/website/templates/**` imports from `apps/website/lib/display-objects/*`
|
||||
|
||||
### 8.2 ESLint restrictions
|
||||
|
||||
Add ESLint restrictions that enforce the same rules at authoring time.
|
||||
|
||||
## 9) Migration steps (dashboard first, then profile)
|
||||
|
||||
### 9.1 Dashboard
|
||||
|
||||
Starting point: [apps/website/app/dashboard/page.tsx](apps/website/app/dashboard/page.tsx:1).
|
||||
|
||||
Steps:
|
||||
|
||||
1) Introduce `DashboardPageQuery` under `apps/website/lib/page-queries/*` that returns a Dashboard Page DTO.
|
||||
2) Change the dashboard server page to call the query and render `DashboardPageClient`.
|
||||
3) Create `DashboardPageClient` as client module:
|
||||
- Initial render: builds ViewData from DTO and renders [`DashboardTemplate`](apps/website/templates/DashboardTemplate.tsx:1).
|
||||
- Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template.
|
||||
4) Ensure any display formatting is implemented as Display Objects.
|
||||
|
||||
### 9.2 Profile
|
||||
|
||||
Starting point: [apps/website/app/profile/page.tsx](apps/website/app/profile/page.tsx:1).
|
||||
|
||||
Steps:
|
||||
|
||||
1) Move all helper logic out of the page module into a template and Display Objects.
|
||||
2) Make profile `page.tsx` a server component that calls a query class returning [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
|
||||
3) Create `ProfilePageClient` as client module:
|
||||
- Initial render: builds ViewData from DTO and renders the template.
|
||||
- Post-hydration: instantiates [`DriverProfileViewModel`](apps/website/lib/view-models/DriverProfileViewModel.ts:108), builds enhanced ViewData, re-renders template.
|
||||
4) Remove all formatting in the page module, including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430).
|
||||
|
||||
## 10) Acceptance criteria
|
||||
|
||||
1) No hydration mismatch warnings on dashboard and profile.
|
||||
2) No ViewModel instantiation in server modules.
|
||||
3) No formatting/sorting/filtering logic in any module under [apps/website/app](apps/website/app/page.tsx:1).
|
||||
4) All formatting is encapsulated by Display Objects under `apps/website/lib/display-objects/*`.
|
||||
@@ -1,540 +0,0 @@
|
||||
# Ratings Architecture Concept (Multi-Rating + Transparency + Eligibility)
|
||||
|
||||
This concept defines a **clean, extendable architecture** for ratings in GridPilot with:
|
||||
- Our own platform ratings (computed only from GridPilot league activity).
|
||||
- External per-game ratings (e.g. iRacing iRating/SR) stored separately for **display + eligibility filtering** only.
|
||||
- A **transparent rating ledger** so users can see exactly why they gained/lost rating.
|
||||
|
||||
It is designed to fit the project’s Clean Architecture + CQRS Light rules in:
|
||||
- [`ARCHITECTURE.md`](docs/ARCHITECTURE.md:1)
|
||||
- [`Domain Objects`](docs/architecture/DOMAIN_OBJECTS.md:1)
|
||||
- [`CQRS Light`](docs/architecture/CQRS.md:1)
|
||||
- [`Use Cases`](docs/architecture/USECASES.md:1)
|
||||
- [`View Models`](docs/architecture/VIEW_MODELS.md:1)
|
||||
|
||||
It is also aligned with the principles in:
|
||||
- [`GridPilot Rating`](docs/concept/RATING.md:1)
|
||||
- [`Stats`](docs/concept/STATS.md:1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Requirements Summary
|
||||
|
||||
### 1.1 Must Have (now)
|
||||
- **Platform ratings**
|
||||
- `driving`: combines clean + fast driving (and also accounts for AFK/DNS/DNF/DSQ).
|
||||
- `adminTrust`: administrative trust score.
|
||||
- **Per-game ratings**
|
||||
- Stored per game (e.g. iRacing `iRating`, `safetyRating`) for display + eligibility filters.
|
||||
- Not used to compute platform ratings.
|
||||
- **Transparency**
|
||||
- UI must show “why did my rating change” with plus/minus, reason, and reference context.
|
||||
- A persisted rating ledger is required.
|
||||
|
||||
### 1.2 Future (design for, do not implement now)
|
||||
- `stewardTrust`
|
||||
- `broadcasterTrust`
|
||||
|
||||
### 1.3 Non-Functional
|
||||
- Architecture is **easy to maintain** and **easy to access** (used across many locations).
|
||||
- Strong separation of concerns: domain is pure; commands enforce invariants; queries are pragmatic.
|
||||
- Extendability: new rating dimensions and new event types should not cause rewrites.
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Architectural Decisions
|
||||
|
||||
### 2.1 Platform ratings are computed only from GridPilot events
|
||||
External game ratings are:
|
||||
- Stored independently,
|
||||
- Displayed and queried,
|
||||
- Usable in eligibility filters,
|
||||
- Not inputs to platform rating computation.
|
||||
|
||||
### 2.2 Ledger-first transparency
|
||||
Every rating adjustment is represented as an immutable **rating event** in a ledger, with:
|
||||
- Who: userId (subject)
|
||||
- What: dimension (driving/adminTrust/…)
|
||||
- Why: reason code + human-readable summary + structured metadata
|
||||
- How much: delta (+/-) and optional weight
|
||||
- Where: reference to a domain object (raceId, penaltyId, voteId, adminActionId)
|
||||
|
||||
Snapshots are derived from ledger events, not the other way around.
|
||||
|
||||
### 2.3 CQRS Light split
|
||||
- Commands record rating events and recompute snapshots.
|
||||
- Queries provide fast read models for UI and eligibility evaluation, without loading domain aggregates.
|
||||
|
||||
### 2.4 Evolution path from existing code
|
||||
There is already a multi-dimensional value object [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1) and a domain service [`RatingUpdateService`](core/identity/domain/services/RatingUpdateService.ts:1) triggered by [`CompleteRaceUseCaseWithRatings.execute()`](core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts:47).
|
||||
|
||||
This concept treats the existing [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1) as an early “snapshot-like” model and proposes a controlled evolution:
|
||||
- Keep a snapshot object (can stay named `UserRating` or be renamed later).
|
||||
- Add a ledger model + repositories + calculators.
|
||||
- Gradually redirect the write flow from “direct updates” to “record events + recompute snapshot”.
|
||||
|
||||
No “big bang rewrite”.
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain Model (Core Concepts)
|
||||
|
||||
### 3.1 Bounded contexts
|
||||
- **Identity context** owns user reputation/ratings (consistent with current placement of [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1)).
|
||||
- **Racing context** emits race outcomes (finishes, incidents, statuses) and penalties/DSQ information; it does not own rating logic.
|
||||
- **Admin/Competition context** emits admin actions and vote outcomes; it does not own rating logic.
|
||||
|
||||
### 3.2 Rating dimensions (extendable)
|
||||
Define a canonical dimension key set (enum-like union) for platform ratings:
|
||||
|
||||
- `driving`
|
||||
- `adminTrust`
|
||||
- `stewardTrust` (future)
|
||||
- `broadcasterTrust` (future)
|
||||
|
||||
Rule: adding a dimension should require:
|
||||
- A new calculator strategy, and
|
||||
- New event taxonomy entries,
|
||||
not structural redesign.
|
||||
|
||||
### 3.3 Domain objects (suggested)
|
||||
Domain objects below follow the rules in [`Domain Objects`](docs/architecture/DOMAIN_OBJECTS.md:1).
|
||||
|
||||
**Value Objects**
|
||||
- `RatingDimensionKey` (e.g. `driving`, `adminTrust`)
|
||||
- `RatingValue` (0..100 or 0..N; pick one standard scale; recommend 0..100 aligned with [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1))
|
||||
- `RatingDelta` (signed float/decimal; stored and displayed)
|
||||
- `RatingEventId` (uuid-like string)
|
||||
- `RatingReference` (typed reference union: raceId, penaltyId, voteId, adminActionId)
|
||||
- `ExternalRating` (per-game rating data point, e.g. iRating, safety rating)
|
||||
- `GameKey` (e.g. `iracing`, future `acc`, etc.)
|
||||
|
||||
**Entities / Aggregate Roots**
|
||||
- `RatingLedger` (aggregate root for a user’s rating events)
|
||||
- Identity: `userId`
|
||||
- Holds a list/stream of `RatingEvent` (not necessarily loaded fully; repository can stream)
|
||||
- `RatingEvent` (entity inside ledger or separate entity persisted in table)
|
||||
- Identity: `ratingEventId`
|
||||
- Immutable once persisted
|
||||
- `AdminVoteSession` (aggregate root, scoped to league + admin candidate + window)
|
||||
- Identity: `voteSessionId`
|
||||
- Controls who can vote, dedup, time window, and closure
|
||||
- Emits outcome events that convert to rating ledger events
|
||||
- `ExternalGameRatingProfile` (aggregate root per user)
|
||||
- Identity: `userId + gameKey`
|
||||
- Stores latest known per-game ratings + provenance
|
||||
|
||||
**Domain Services**
|
||||
- `DrivingRatingCalculator` (pure, stateless)
|
||||
- `AdminTrustRatingCalculator` (pure, stateless)
|
||||
- `RatingSnapshotCalculator` (applies ordered events to snapshot)
|
||||
- `RatingEventFactory` (turns domain facts into rating events)
|
||||
- `EligibilityEvaluator` (pure evaluation over rating snapshots and external ratings, but invoked from application layer for “decisions”)
|
||||
- Keep services similar in spirit to [`AverageStrengthOfFieldCalculator.calculate()`](core/racing/domain/services/StrengthOfFieldCalculator.ts:29) and constraints typical of value objects like [`StrengthOfField.create()`](core/racing/domain/value-objects/StrengthOfField.ts:22).
|
||||
|
||||
### 3.4 Rating snapshot (current `UserRating`)
|
||||
A snapshot is what most screens need:
|
||||
- latest rating value per dimension,
|
||||
- confidence/sample size/trend,
|
||||
- lastUpdated.
|
||||
|
||||
This already exists in [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1). Conceptually, the snapshot is derived from events:
|
||||
- `value`: derived
|
||||
- `confidence` + `sampleSize`: derived from count/weights and recentness rules
|
||||
- `trend`: derived from recent deltas
|
||||
|
||||
Snapshots are persisted for fast reads; events are persisted for transparency.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rating Ledger (Transparency Backbone)
|
||||
|
||||
### 4.1 Rating event structure (conceptual schema)
|
||||
A `RatingEvent` should contain:
|
||||
|
||||
- `id`: `RatingEventId`
|
||||
- `userId`: subject of the rating
|
||||
- `dimension`: `RatingDimensionKey`
|
||||
- `delta`: `RatingDelta`
|
||||
- `weight`: numeric (optional; for sample size / confidence)
|
||||
- `occurredAt`: Date
|
||||
- `createdAt`: Date
|
||||
- `source`:
|
||||
- `sourceType`: `race` | `penalty` | `vote` | `adminAction` | `manualAdjustment`
|
||||
- `sourceId`: string
|
||||
- `reason`:
|
||||
- `code`: stable machine code (for i18n and filtering)
|
||||
- `summary`: human text (or key + template params)
|
||||
- `details`: structured JSON (for UI)
|
||||
- `visibility`:
|
||||
- `public`: boolean (default true)
|
||||
- `redactedFields`: list (for sensitive moderation info)
|
||||
- `version`: schema version for forward compatibility
|
||||
|
||||
### 4.2 Ledger invariants
|
||||
- Immutable events (append-only); corrections happen via compensating events.
|
||||
- Deterministic ordering rule (by `occurredAt`, then `createdAt`, then `id`).
|
||||
- The snapshot is always reproducible from events (within the same calculator version).
|
||||
|
||||
### 4.3 Calculator versioning
|
||||
To remain maintainable over time:
|
||||
- Events reference a `calculatorVersion` used when they were generated (optional but recommended).
|
||||
- Snapshot stores the latest `calculatorVersion`.
|
||||
- When the algorithm changes, snapshots can be recomputed in background; events remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform Rating Definitions
|
||||
|
||||
### 5.1 Driving rating (clean + fast + reliability)
|
||||
Driving rating is the platform’s main driver identity rating (as described in [`GridPilot Rating`](docs/concept/RATING.md:1)).
|
||||
|
||||
It is derived from ledger events sourced from race facts:
|
||||
- Finishing position vs field strength (fast driving component)
|
||||
- Incidents and penalty involvement (clean driving component)
|
||||
- Attendance and reliability (DNS/DNF/DSQ/AFK)
|
||||
|
||||
#### 5.1.1 Driver status inputs
|
||||
We must explicitly model:
|
||||
- AFK
|
||||
- DNS (did not start)
|
||||
- DNF (did not finish)
|
||||
- DSQ (disqualified)
|
||||
|
||||
These should become explicit event types, not hidden inside one “performance score”.
|
||||
|
||||
#### 5.1.2 Driving event taxonomy (initial)
|
||||
Examples of ledger event reason codes (illustrative; final list is a product decision):
|
||||
|
||||
Performance:
|
||||
- `DRIVING_FINISH_STRENGTH_GAIN`
|
||||
- `DRIVING_POSITIONS_GAINED_BONUS`
|
||||
- `DRIVING_PACE_RELATIVE_GAIN` (optional)
|
||||
|
||||
Clean driving:
|
||||
- `DRIVING_INCIDENTS_PENALTY`
|
||||
- `DRIVING_MAJOR_CONTACT_PENALTY` (if severity exists)
|
||||
- `DRIVING_PENALTY_INVOLVEMENT_PENALTY`
|
||||
|
||||
Reliability:
|
||||
- `DRIVING_DNS_PENALTY`
|
||||
- `DRIVING_DNF_PENALTY`
|
||||
- `DRIVING_DSQ_PENALTY`
|
||||
- `DRIVING_AFK_PENALTY`
|
||||
- `DRIVING_SEASON_ATTENDANCE_BONUS` (optional later)
|
||||
|
||||
Each event must reference source facts:
|
||||
- `raceId` always for race-derived events
|
||||
- `penaltyId` for steward/admin penalty events
|
||||
- additional metadata: start position, finish position, incident count, etc.
|
||||
|
||||
#### 5.1.3 Field strength support
|
||||
Driving performance should consider strength of field similar to the existing value object [`StrengthOfField`](core/racing/domain/value-objects/StrengthOfField.ts:1) and its service pattern in [`StrengthOfFieldCalculator`](core/racing/domain/services/StrengthOfFieldCalculator.ts:1).
|
||||
|
||||
Concept: the driving calculator receives:
|
||||
- driver finish data
|
||||
- field rating inputs (which can be platform driving snapshot values or external iRating for SoF only, depending on product choice)
|
||||
|
||||
Given the earlier decision “platform rating does not use external ratings”, we can still compute SoF using:
|
||||
- platform driving snapshot values (for users with sufficient data), and/or
|
||||
- a neutral default for new users
|
||||
without using external ratings as an input to driving rating itself.
|
||||
|
||||
(If SoF must use iRating for accuracy, it still does not violate “independent” as long as SoF is a *race context signal* and not a *direct driver rating input*. This is a design choice to confirm later.)
|
||||
|
||||
### 5.2 Admin trust rating (hybrid system signals + votes)
|
||||
Admin trust is separate from driving.
|
||||
|
||||
It must include:
|
||||
- System-derived actions (timeliness, reversals, consistency, completion of tasks)
|
||||
- Driver votes among participants in a league
|
||||
|
||||
#### 5.2.1 Voting model (anti-abuse, league-scoped)
|
||||
Votes are generated within a league, but the rating is global. To avoid abuse:
|
||||
- Only eligible voters: drivers who participated in the league (membership + minimum participation threshold).
|
||||
- 1 vote per voter per admin per voting window.
|
||||
- Voting windows are timeboxed (e.g. weekly/monthly/season-end).
|
||||
- Votes have reduced weight if the voter has low trust (optional later).
|
||||
- Votes should be explainable: aggregated outcome + distribution; individual votes may be private.
|
||||
|
||||
Votes produce ledger events:
|
||||
- `ADMIN_VOTE_OUTCOME_POSITIVE`
|
||||
- `ADMIN_VOTE_OUTCOME_NEGATIVE`
|
||||
with reference `voteSessionId` and metadata including:
|
||||
- leagueId
|
||||
- eligibleVoterCount
|
||||
- voteCount
|
||||
- percentPositive
|
||||
|
||||
#### 5.2.2 Admin system-signal taxonomy (initial)
|
||||
Examples:
|
||||
- `ADMIN_ACTION_SLA_BONUS` (responded within SLA)
|
||||
- `ADMIN_ACTION_REVERSAL_PENALTY` (frequent reversals)
|
||||
- `ADMIN_ACTION_RULE_CLARITY_BONUS` (published rules/changes; if tracked)
|
||||
- `ADMIN_ACTION_ABUSE_REPORT_PENALTY` (validated abuse reports)
|
||||
|
||||
All of these should be “facts” emitted by admin/competition workflows, not computed in rating domain from raw infra signals.
|
||||
|
||||
---
|
||||
|
||||
## 6. External Game Ratings (Per-Game Profiles)
|
||||
|
||||
### 6.1 Purpose
|
||||
External ratings exist to:
|
||||
- Display on user profiles
|
||||
- Be used in eligibility filters
|
||||
|
||||
They do not affect platform ratings.
|
||||
|
||||
### 6.2 Data model (conceptual)
|
||||
`ExternalGameRatingProfile` per `userId + gameKey` stores:
|
||||
- `gameKey`: e.g. `iracing`
|
||||
- `ratings`: map of rating type -> numeric value
|
||||
- e.g. `iracing.iRating`, `iracing.safetyRating`
|
||||
- `provenance`:
|
||||
- `source`: `iracing-api` | `manual` | `import`
|
||||
- `lastSyncedAt`
|
||||
- `confidence`/`verified` flag (optional)
|
||||
|
||||
### 6.3 Read surfaces
|
||||
Queries should provide:
|
||||
- “latest ratings by game”
|
||||
- “rating history by game” (optional future)
|
||||
- “last sync status”
|
||||
|
||||
---
|
||||
|
||||
## 7. Application Layer (Commands and Queries)
|
||||
|
||||
### 7.1 Command side (write model)
|
||||
Commands are use-cases that:
|
||||
- validate permissions
|
||||
- load required domain facts (race outcomes, votes)
|
||||
- create rating events
|
||||
- append to ledger
|
||||
- recompute snapshot(s)
|
||||
- persist results
|
||||
|
||||
Must follow [`Use Cases`](docs/architecture/USECASES.md:1): output via presenter/output port, no DTO leakage.
|
||||
|
||||
#### 7.1.1 Command use cases (proposed)
|
||||
Driving:
|
||||
- `RecordRaceRatingEventsUseCase`
|
||||
- Input: `raceId`
|
||||
- Loads race results (positions, incidents, statuses)
|
||||
- Produces ledger events for driving
|
||||
- `ApplyPenaltyRatingEventUseCase`
|
||||
- Input: `penaltyId`
|
||||
- Produces event(s) affecting driving and/or fairness dimension
|
||||
|
||||
Admin trust:
|
||||
- `OpenAdminVoteSessionUseCase`
|
||||
- `CastAdminVoteUseCase`
|
||||
- `CloseAdminVoteSessionUseCase`
|
||||
- On close: create ledger event(s) from aggregated vote outcome
|
||||
- `RecordAdminActionRatingEventUseCase`
|
||||
- Called by admin workflows to translate system events into rating events
|
||||
|
||||
Snapshots:
|
||||
- `RecomputeUserRatingSnapshotUseCase`
|
||||
- Input: `userId` (or batch)
|
||||
- Replays ledger events through calculator to update snapshot
|
||||
|
||||
External ratings:
|
||||
- `UpsertExternalGameRatingUseCase`
|
||||
- Input: userId, gameKey, rating values, provenance
|
||||
|
||||
### 7.2 Query side (read model)
|
||||
Queries must be pragmatic per [`CQRS Light`](docs/architecture/CQRS.md:1), and should not use domain entities.
|
||||
|
||||
#### 7.2.1 Query use cases (proposed)
|
||||
User-facing:
|
||||
- `GetUserRatingsSummaryQuery`
|
||||
- returns current platform snapshot values + external game ratings + last updated timestamps
|
||||
- `GetUserRatingLedgerQuery`
|
||||
- returns paginated ledger events, filterable by dimension, date range, reason code
|
||||
- `GetUserRatingChangeExplanationQuery`
|
||||
- returns a “why” view for a time window (e.g. last race), pre-grouped by race/vote/penalty
|
||||
|
||||
League-facing:
|
||||
- `GetLeagueEligibilityPreviewQuery`
|
||||
- evaluates candidate eligibility for a league filter and returns explanation (which condition failed)
|
||||
|
||||
Leaderboards:
|
||||
- `GetTopDrivingRatingsQuery`
|
||||
- `GetTopAdminTrustQuery`
|
||||
|
||||
---
|
||||
|
||||
## 8. Eligibility Filters (Leagues)
|
||||
|
||||
### 8.1 Requirements
|
||||
Leagues can define eligibility filters against:
|
||||
- Platform `driving` rating (and future dimensions)
|
||||
- External per-game ratings (e.g. iRating threshold)
|
||||
|
||||
Eligibility decisions should be explainable (audit trail and UI explanation).
|
||||
|
||||
### 8.2 Filter DSL (typed, explainable)
|
||||
Define a small filter language that supports:
|
||||
|
||||
- target:
|
||||
- `platform.driving`
|
||||
- `platform.adminTrust`
|
||||
- `external.iracing.iRating`
|
||||
- `external.iracing.safetyRating`
|
||||
|
||||
- operators:
|
||||
- `>=`, `>`, `<=`, `<`, `between`, `exists`
|
||||
|
||||
- composition:
|
||||
- `and`, `or`
|
||||
|
||||
Each evaluation returns:
|
||||
- `eligible: boolean`
|
||||
- `reasons: []` each with:
|
||||
- `target`
|
||||
- `operator`
|
||||
- `expected`
|
||||
- `actual`
|
||||
- `pass/fail`
|
||||
|
||||
This makes it UI-transparent and debuggable.
|
||||
|
||||
---
|
||||
|
||||
## 9. Website / UI Transparency Contract
|
||||
|
||||
Per [`View Models`](docs/architecture/VIEW_MODELS.md:1), UI should consume view models built from query DTOs.
|
||||
|
||||
### 9.1 “Ratings” surfaces (suggested)
|
||||
- User profile:
|
||||
- Platform driving rating + trend + confidence
|
||||
- Admin trust rating (if relevant)
|
||||
- External game ratings section (iRating/SR)
|
||||
- “Why did my rating change?” page:
|
||||
- Ledger list with grouping by race/vote/penalty
|
||||
- Each entry: delta, reason, context (race link), and explanation
|
||||
- League eligibility panel:
|
||||
- Filter configured + explanation of pass/fail for a given user
|
||||
- Should be able to show: “iRating 2200 is below required 2500” and/or “driving 61 is above required 55”
|
||||
|
||||
---
|
||||
|
||||
## 10. Event Flow Examples
|
||||
|
||||
### 10.1 Race completion updates driving rating
|
||||
Triggered today by [`CompleteRaceUseCaseWithRatings.execute()`](core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts:47) which calls [`RatingUpdateService.updateDriverRatingsAfterRace()`](core/identity/domain/services/RatingUpdateService.ts:21).
|
||||
|
||||
Target flow (conceptually):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
RaceCompleted[Race completed]
|
||||
Cmd[RecordRaceRatingEventsUseCase]
|
||||
Ledger[Append rating events]
|
||||
Calc[DrivingRatingCalculator]
|
||||
Snap[Persist snapshot]
|
||||
Query[GetUserRatingLedgerQuery]
|
||||
UI[Profile and Why view]
|
||||
|
||||
RaceCompleted --> Cmd
|
||||
Cmd --> Ledger
|
||||
Cmd --> Calc
|
||||
Calc --> Snap
|
||||
Snap --> Query
|
||||
Ledger --> Query
|
||||
Query --> UI
|
||||
```
|
||||
|
||||
### 10.2 Admin vote updates admin trust
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Open[OpenAdminVoteSessionUseCase]
|
||||
Cast[CastAdminVoteUseCase]
|
||||
Close[CloseAdminVoteSessionUseCase]
|
||||
Ledger[Append vote outcome event]
|
||||
Calc[AdminTrustRatingCalculator]
|
||||
Snap[Persist snapshot]
|
||||
UI[Admin trust breakdown]
|
||||
|
||||
Open --> Cast
|
||||
Cast --> Close
|
||||
Close --> Ledger
|
||||
Close --> Calc
|
||||
Calc --> Snap
|
||||
Snap --> UI
|
||||
Ledger --> UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Maintainability Notes
|
||||
|
||||
### 11.1 Keep calculators pure
|
||||
All rating computations should be pure functions of:
|
||||
- Events
|
||||
- Inputs (like race facts)
|
||||
- Current snapshot (optional)
|
||||
No repositories, no IO.
|
||||
|
||||
### 11.2 Stable reason codes
|
||||
Reason codes must be stable to support:
|
||||
- filtering
|
||||
- analytics
|
||||
- translations
|
||||
- consistent UI explanation
|
||||
|
||||
### 11.3 Explicit extendability
|
||||
Adding `stewardTrust` later should follow the same template:
|
||||
- Add event taxonomy
|
||||
- Add calculator
|
||||
- Add ledger reasons
|
||||
- Add snapshot dimension
|
||||
- Add queries and UI
|
||||
|
||||
No architecture changes.
|
||||
|
||||
---
|
||||
|
||||
## 12. Fit with existing `UserRating` and `RatingUpdateService`
|
||||
|
||||
### 12.1 Current state
|
||||
- Snapshot-like model exists as [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1)
|
||||
- Race completion triggers rating updates via [`RatingUpdateService`](core/identity/domain/services/RatingUpdateService.ts:1)
|
||||
|
||||
### 12.2 Recommended evolution
|
||||
- Introduce ledger persistence and repositories first.
|
||||
- Update the write path so [`RatingUpdateService.updateDriverRatingsAfterRace()`](core/identity/domain/services/RatingUpdateService.ts:21) becomes:
|
||||
- event generation + append + snapshot recalculation
|
||||
- not direct “set newValue”
|
||||
|
||||
This preserves the public API while improving transparency and extensibility.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Decisions (to confirm before implementation)
|
||||
|
||||
1. Strength of Field inputs:
|
||||
- Should SoF use platform driving snapshots only, or may it use external iRating as a contextual “field difficulty” signal while still keeping platform ratings independent?
|
||||
|
||||
2. Scale:
|
||||
- Keep 0..100 scale for platform ratings (consistent with [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1))?
|
||||
|
||||
3. Privacy:
|
||||
- Which admin trust vote details are public (aggregates only) vs private (individual votes)?
|
||||
|
||||
4. Penalty integration:
|
||||
- Which penalties affect driving vs admin trust, and how do we ensure moderation-sensitive info can be redacted while keeping rating transparency?
|
||||
|
||||
---
|
||||
|
||||
## 14. Next Step: Implementation Planning Checklist
|
||||
|
||||
Implementation should proceed in small vertical slices:
|
||||
- Ledger persistence + query read models
|
||||
- Driving rating events from race completion including DNS/DNF/DSQ/AFK
|
||||
- Admin vote sessions and rating events
|
||||
- Eligibility filter DSL + evaluation query
|
||||
|
||||
All aligned with the project’s CQRS Light patterns in [`CQRS Light`](docs/architecture/CQRS.md:1).
|
||||
@@ -1,606 +0,0 @@
|
||||
# Clean Architecture Violations & Refactoring Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Recent changes introduced severe violations of Clean Architecture principles by creating singleton stores and in-memory services that bypass proper dependency injection and repository patterns. This document outlines all violations and provides a comprehensive refactoring strategy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Violations Identified
|
||||
|
||||
### 1.1 Critical Violations
|
||||
|
||||
#### **Singleton Pattern in Adapters Layer**
|
||||
- **File**: `adapters/racing/services/DriverStatsStore.ts`
|
||||
- **Violation**: Global singleton with `getInstance()` static method
|
||||
- **Impact**: Bypasses dependency injection, creates hidden global state
|
||||
- **Lines**: 7-18
|
||||
|
||||
#### **In-Memory Services Using Singletons**
|
||||
- **File**: `adapters/racing/services/InMemoryRankingService.ts`
|
||||
- **Violation**: Direct singleton access via `DriverStatsStore.getInstance()` (line 14)
|
||||
- **Impact**: Service depends on global state, not injectable dependencies
|
||||
- **Lines**: 14
|
||||
|
||||
#### **In-Memory Driver Stats Service**
|
||||
- **File**: `adapters/racing/services/InMemoryDriverStatsService.ts`
|
||||
- **Violation**: Uses singleton store instead of repository pattern
|
||||
- **Impact**: Business logic depends on infrastructure implementation
|
||||
- **Lines**: 10
|
||||
|
||||
#### **Team Stats Store**
|
||||
- **File**: `adapters/racing/services/TeamStatsStore.ts`
|
||||
- **Violation**: Same singleton pattern as DriverStatsStore
|
||||
- **Impact**: Global state management in adapters layer
|
||||
|
||||
### 1.2 Architecture Violations
|
||||
|
||||
#### **Domain Services in Adapters**
|
||||
- **Location**: `adapters/racing/services/`
|
||||
- **Violation**: Services should be in domain layer, not adapters
|
||||
- **Impact**: Mixes application logic with infrastructure
|
||||
|
||||
#### **Hardcoded Data Sources**
|
||||
- **Issue**: RankingService computes from singleton store, not real data
|
||||
- **Impact**: Rankings don't reflect actual race results/standings
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture Principles Violated
|
||||
|
||||
### 2.1 Dependency Rule
|
||||
**Principle**: Dependencies must point inward (domain → application → adapters → frameworks)
|
||||
|
||||
**Violation**:
|
||||
- `InMemoryRankingService` (adapters) → `DriverStatsStore` (singleton global)
|
||||
- This creates a dependency on global state, not domain abstractions
|
||||
|
||||
### 2.2 Dependency Injection
|
||||
**Principle**: All dependencies must be injected, never fetched
|
||||
|
||||
**Violation**:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const statsStore = DriverStatsStore.getInstance();
|
||||
|
||||
// ✅ CORRECT
|
||||
constructor(private readonly statsRepository: IDriverStatsRepository) {}
|
||||
```
|
||||
|
||||
### 2.3 Repository Pattern
|
||||
**Principle**: Persistence concerns belong in repositories, not services
|
||||
|
||||
**Violation**:
|
||||
- `DriverStatsStore` acts as a repository but is a singleton
|
||||
- Services directly access store instead of using repository interfaces
|
||||
|
||||
### 2.4 Domain Service Purity
|
||||
**Principle**: Domain services contain business logic, no persistence
|
||||
|
||||
**Violation**:
|
||||
- `InMemoryRankingService` is in adapters, not domain
|
||||
- It contains persistence logic (reading from store)
|
||||
|
||||
---
|
||||
|
||||
## 3. Proper Architecture Specification
|
||||
|
||||
### 3.1 Correct Layer Structure
|
||||
|
||||
```
|
||||
core/racing/
|
||||
├── domain/
|
||||
│ ├── services/
|
||||
│ │ ├── IRankingService.ts # Domain interface
|
||||
│ │ └── IDriverStatsService.ts # Domain interface
|
||||
│ └── repositories/
|
||||
│ ├── IResultRepository.ts # Persistence port
|
||||
│ ├── IStandingRepository.ts # Persistence port
|
||||
│ └── IDriverRepository.ts # Persistence port
|
||||
|
||||
adapters/racing/
|
||||
├── persistence/
|
||||
│ ├── inmemory/
|
||||
│ │ ├── InMemoryResultRepository.ts
|
||||
│ │ ├── InMemoryStandingRepository.ts
|
||||
│ │ └── InMemoryDriverRepository.ts
|
||||
│ └── typeorm/
|
||||
│ ├── TypeOrmResultRepository.ts
|
||||
│ ├── TypeOrmStandingRepository.ts
|
||||
│ └── TypeOrmDriverRepository.ts
|
||||
|
||||
apps/api/racing/
|
||||
├── controllers/
|
||||
├── services/
|
||||
└── presenters/
|
||||
```
|
||||
|
||||
### 3.2 Domain Services (Pure Business Logic)
|
||||
|
||||
**Location**: `core/racing/domain/services/`
|
||||
|
||||
**Characteristics**:
|
||||
- No persistence
|
||||
- No singletons
|
||||
- Pure business logic
|
||||
- Injected dependencies via interfaces
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// core/racing/domain/services/RankingService.ts
|
||||
export class RankingService implements IRankingService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
// Query real data from repositories
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
|
||||
// Compute rankings from actual data
|
||||
return this.computeRankings(standings, drivers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Repository Pattern
|
||||
|
||||
**Location**: `adapters/racing/persistence/`
|
||||
|
||||
**Characteristics**:
|
||||
- Implement domain repository interfaces
|
||||
- Handle persistence details
|
||||
- No singletons
|
||||
- Injected via constructor
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts
|
||||
export class InMemoryStandingRepository implements IStandingRepository {
|
||||
private standings = new Map<string, Standing>();
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Standing[]> {
|
||||
return Array.from(this.standings.values())
|
||||
.filter(s => s.leagueId === leagueId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
|
||||
async save(standing: Standing): Promise<Standing> {
|
||||
const key = `${standing.leagueId}-${standing.driverId}`;
|
||||
this.standings.set(key, standing);
|
||||
return standing;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Refactoring Strategy
|
||||
|
||||
### 4.1 Remove Violating Files
|
||||
|
||||
**DELETE**:
|
||||
- `adapters/racing/services/DriverStatsStore.ts`
|
||||
- `adapters/racing/services/TeamStatsStore.ts`
|
||||
- `adapters/racing/services/InMemoryRankingService.ts`
|
||||
- `adapters/racing/services/InMemoryDriverStatsService.ts`
|
||||
|
||||
### 4.2 Create Proper Domain Services
|
||||
|
||||
**CREATE**: `core/racing/domain/services/RankingService.ts`
|
||||
```typescript
|
||||
export class RankingService implements IRankingService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getAllDriverRankings(): Promise<DriverRanking[]> {
|
||||
this.logger.debug('[RankingService] Computing rankings from standings');
|
||||
|
||||
const standings = await this.standingRepository.findAll();
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
|
||||
// Group standings by driver and compute stats
|
||||
const driverStats = new Map<string, { rating: number; wins: number; races: number }>();
|
||||
|
||||
for (const standing of standings) {
|
||||
const existing = driverStats.get(standing.driverId) || { rating: 0, wins: 0, races: 0 };
|
||||
existing.races++;
|
||||
if (standing.position === 1) existing.wins++;
|
||||
existing.rating += this.calculateRating(standing.position);
|
||||
driverStats.set(standing.driverId, existing);
|
||||
}
|
||||
|
||||
// Convert to rankings
|
||||
const rankings: DriverRanking[] = Array.from(driverStats.entries()).map(([driverId, stats]) => ({
|
||||
driverId,
|
||||
rating: Math.round(stats.rating / stats.races),
|
||||
wins: stats.wins,
|
||||
totalRaces: stats.races,
|
||||
overallRank: null
|
||||
}));
|
||||
|
||||
// Sort by rating and assign ranks
|
||||
rankings.sort((a, b) => b.rating - a.rating);
|
||||
rankings.forEach((r, idx) => r.overallRank = idx + 1);
|
||||
|
||||
return rankings;
|
||||
}
|
||||
|
||||
private calculateRating(position: number): number {
|
||||
// iRacing-style rating calculation
|
||||
const base = 1000;
|
||||
const points = Math.max(0, 25 - position);
|
||||
return base + (points * 50);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CREATE**: `core/racing/domain/services/DriverStatsService.ts`
|
||||
```typescript
|
||||
export class DriverStatsService implements IDriverStatsService {
|
||||
constructor(
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
const results = await this.resultRepository.findByDriverId(driverId);
|
||||
const standings = await this.standingRepository.findAll();
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const wins = results.filter(r => r.position === 1).length;
|
||||
const podiums = results.filter(r => r.position <= 3).length;
|
||||
const totalRaces = results.length;
|
||||
|
||||
// Calculate rating from standings
|
||||
const driverStanding = standings.find(s => s.driverId === driverId);
|
||||
const rating = driverStanding ? this.calculateRatingFromStanding(driverStanding) : 1000;
|
||||
|
||||
// Find overall rank
|
||||
const sortedStandings = standings.sort((a, b) => b.points - a.points);
|
||||
const rankIndex = sortedStandings.findIndex(s => s.driverId === driverId);
|
||||
const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
wins,
|
||||
podiums,
|
||||
totalRaces,
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
|
||||
private calculateRatingFromStanding(standing: Standing): number {
|
||||
// Calculate based on position and points
|
||||
return Math.round(1000 + (standing.points * 10));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Create Proper Repositories
|
||||
|
||||
**CREATE**: `adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts`
|
||||
```typescript
|
||||
export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
|
||||
private stats = new Map<string, DriverStats>();
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
return this.stats.get(driverId) || null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
this.stats.set(driverId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
return new Map(this.stats);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Update Seed Data Strategy
|
||||
|
||||
**Current Problem**: Seeds populate singleton stores directly
|
||||
|
||||
**New Strategy**: Seed proper repositories, compute stats from results
|
||||
|
||||
**UPDATE**: `adapters/bootstrap/SeedRacingData.ts`
|
||||
```typescript
|
||||
export class SeedRacingData {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly seedDeps: RacingSeedDependencies,
|
||||
private readonly statsRepository: IDriverStatsRepository // NEW
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// ... existing seeding logic ...
|
||||
|
||||
// After seeding results and standings, compute and store stats
|
||||
await this.computeAndStoreDriverStats();
|
||||
await this.computeAndStoreTeamStats();
|
||||
}
|
||||
|
||||
private async computeAndStoreDriverStats(): Promise<void> {
|
||||
const drivers = await this.seedDeps.driverRepository.findAll();
|
||||
const standings = await this.seedDeps.standingRepository.findAll();
|
||||
|
||||
for (const driver of drivers) {
|
||||
const driverStandings = standings.filter(s => s.driverId === driver.id);
|
||||
if (driverStandings.length === 0) continue;
|
||||
|
||||
const stats = this.calculateDriverStats(driver, driverStandings);
|
||||
await this.statsRepository.saveDriverStats(driver.id, stats);
|
||||
}
|
||||
|
||||
this.logger.info(`[Bootstrap] Computed stats for ${drivers.length} drivers`);
|
||||
}
|
||||
|
||||
private calculateDriverStats(driver: Driver, standings: Standing[]): DriverStats {
|
||||
const wins = standings.filter(s => s.position === 1).length;
|
||||
const podiums = standings.filter(s => s.position <= 3).length;
|
||||
const totalRaces = standings.length;
|
||||
const avgPosition = standings.reduce((sum, s) => sum + s.position, 0) / totalRaces;
|
||||
|
||||
// Calculate rating based on performance
|
||||
const baseRating = 1000;
|
||||
const performanceBonus = (wins * 100) + (podiums * 50) + Math.max(0, 200 - (avgPosition * 10));
|
||||
const rating = Math.round(baseRating + performanceBonus);
|
||||
|
||||
// Find overall rank
|
||||
const allStandings = await this.seedDeps.standingRepository.findAll();
|
||||
const sorted = allStandings.sort((a, b) => b.points - a.points);
|
||||
const rankIndex = sorted.findIndex(s => s.driverId === driver.id);
|
||||
const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
|
||||
|
||||
return {
|
||||
rating,
|
||||
safetyRating: 85, // Could be computed from penalties/incidents
|
||||
sportsmanshipRating: 4.5, // Could be computed from protests
|
||||
totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
dnfs: totalRaces - wins - podiums, // Approximate
|
||||
avgFinish: avgPosition,
|
||||
bestFinish: Math.min(...standings.map(s => s.position)),
|
||||
worstFinish: Math.max(...standings.map(s => s.position)),
|
||||
consistency: Math.round(100 - (avgPosition * 2)), // Simplified
|
||||
experienceLevel: this.determineExperienceLevel(totalRaces),
|
||||
overallRank
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Data Strategy
|
||||
|
||||
### 5.1 Media Repository Pattern
|
||||
|
||||
**Location**: `adapters/racing/persistence/media/`
|
||||
|
||||
**Purpose**: Handle static assets (logos, images, categories)
|
||||
|
||||
**Structure**:
|
||||
```
|
||||
adapters/racing/persistence/media/
|
||||
├── IMediaRepository.ts
|
||||
├── InMemoryMediaRepository.ts
|
||||
├── FileSystemMediaRepository.ts
|
||||
└── S3MediaRepository.ts
|
||||
```
|
||||
|
||||
**Interface**:
|
||||
```typescript
|
||||
export interface IMediaRepository {
|
||||
getDriverAvatar(driverId: string): Promise<string | null>;
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
getTrackImage(trackId: string): Promise<string | null>;
|
||||
getCategoryIcon(categoryId: string): Promise<string | null>;
|
||||
getSponsorLogo(sponsorId: string): Promise<string | null>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Data Enrichment Strategy
|
||||
|
||||
**Problem**: Frontend needs ratings, wins, categories, logos
|
||||
|
||||
**Solution**:
|
||||
1. **Seed real data** (results, standings, races)
|
||||
2. **Compute stats** from real data
|
||||
3. **Store in repositories** (not singletons)
|
||||
4. **Serve via queries** (CQRS pattern)
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
Seed Results → Compute Standings → Calculate Stats → Store in Repository → Query for Frontend
|
||||
```
|
||||
|
||||
### 5.3 Query Layer for Frontend
|
||||
|
||||
**Location**: `core/racing/application/queries/`
|
||||
|
||||
**Examples**:
|
||||
```typescript
|
||||
// GetDriverProfileQuery.ts
|
||||
export class GetDriverProfileQuery {
|
||||
constructor(
|
||||
private readonly driverStatsRepository: IDriverStatsRepository,
|
||||
private readonly mediaRepository: IMediaRepository,
|
||||
private readonly driverRepository: IDriverRepository
|
||||
) {}
|
||||
|
||||
async execute(driverId: string): Promise<DriverProfileViewModel> {
|
||||
const driver = await this.driverRepository.findById(driverId);
|
||||
const stats = await this.driverStatsRepository.getDriverStats(driverId);
|
||||
const avatar = await this.mediaRepository.getDriverAvatar(driverId);
|
||||
|
||||
return {
|
||||
id: driverId,
|
||||
name: driver.name,
|
||||
avatar,
|
||||
rating: stats.rating,
|
||||
wins: stats.wins,
|
||||
podiums: stats.podiums,
|
||||
totalRaces: stats.totalRaces,
|
||||
rank: stats.overallRank,
|
||||
experienceLevel: stats.experienceLevel
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Steps
|
||||
|
||||
### Phase 1: Remove Violations (Immediate)
|
||||
1. ✅ Delete `DriverStatsStore.ts`
|
||||
2. ✅ Delete `TeamStatsStore.ts`
|
||||
3. ✅ Delete `InMemoryRankingService.ts`
|
||||
4. ✅ Delete `InMemoryDriverStatsService.ts`
|
||||
5. ✅ Remove imports from `SeedRacingData.ts`
|
||||
|
||||
### Phase 2: Create Proper Infrastructure
|
||||
1. ✅ Create `core/racing/domain/services/RankingService.ts`
|
||||
2. ✅ Create `core/racing/domain/services/DriverStatsService.ts`
|
||||
3. ✅ Create `adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts`
|
||||
4. ✅ Create `adapters/racing/persistence/media/InMemoryMediaRepository.ts`
|
||||
|
||||
### Phase 3: Update Seed Logic
|
||||
1. ✅ Modify `SeedRacingData.ts` to compute stats from results
|
||||
2. ✅ Remove singleton store population
|
||||
3. ✅ Add stats repository injection
|
||||
4. ✅ Add media data seeding
|
||||
|
||||
### Phase 4: Update Application Layer
|
||||
1. ✅ Update factories to inject proper services
|
||||
2. ✅ Update controllers to use domain services
|
||||
3. ✅ Update presenters to query repositories
|
||||
|
||||
### Phase 5: Frontend Integration
|
||||
1. ✅ Create query use cases for frontend data
|
||||
2. ✅ Implement media repository for assets
|
||||
3. ✅ Update API endpoints to serve computed data
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Unit Tests
|
||||
```typescript
|
||||
// RankingService.test.ts
|
||||
describe('RankingService', () => {
|
||||
it('computes rankings from real standings', async () => {
|
||||
const mockStandings = [/* real standings */];
|
||||
const mockResults = [/* real results */];
|
||||
|
||||
const service = new RankingService(
|
||||
mockResultRepo,
|
||||
mockStandingRepo,
|
||||
mockDriverRepo,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
const rankings = await service.getAllDriverRankings();
|
||||
|
||||
expect(rankings).toHaveLength(150);
|
||||
expect(rankings[0].overallRank).toBe(1);
|
||||
expect(rankings[0].rating).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 Integration Tests
|
||||
```typescript
|
||||
// SeedRacingData.integration.test.ts
|
||||
describe('SeedRacingData', () => {
|
||||
it('seeds data and computes stats correctly', async () => {
|
||||
const seed = new SeedRacingData(logger, deps, statsRepo);
|
||||
await seed.execute();
|
||||
|
||||
const stats = await statsRepo.getDriverStats(driverId);
|
||||
expect(stats.rating).toBeDefined();
|
||||
expect(stats.wins).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Benefits of This Approach
|
||||
|
||||
### 8.1 Architecture Benefits
|
||||
- ✅ **Clean Architecture Compliance**: Proper layer separation
|
||||
- ✅ **Dependency Injection**: All dependencies injected
|
||||
- ✅ **Testability**: Easy to mock repositories
|
||||
- ✅ **Maintainability**: Clear separation of concerns
|
||||
|
||||
### 8.2 Functional Benefits
|
||||
- ✅ **Real Data**: Rankings computed from actual race results
|
||||
- ✅ **Scalability**: Works with any persistence (memory, Postgres, etc.)
|
||||
- ✅ **Flexibility**: Easy to add new data sources
|
||||
- ✅ **Consistency**: Single source of truth for stats
|
||||
|
||||
### 8.3 Development Benefits
|
||||
- ✅ **No Hidden State**: No singletons
|
||||
- ✅ **Explicit Dependencies**: Clear what each service needs
|
||||
- ✅ **Framework Agnostic**: Core doesn't depend on infrastructure
|
||||
- ✅ **Future Proof**: Easy to migrate to different storage
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration Checklist
|
||||
|
||||
- [ ] Remove all singleton stores
|
||||
- [ ] Remove all in-memory services from adapters/racing/services/
|
||||
- [ ] Create proper domain services in core/racing/domain/services/
|
||||
- [ ] Create proper repositories in adapters/racing/persistence/
|
||||
- [ ] Update SeedRacingData to compute stats from real data
|
||||
- [ ] Update all factories to use dependency injection
|
||||
- [ ] Update controllers to use domain services
|
||||
- [ ] Update presenters to use query patterns
|
||||
- [ ] Add media repository for frontend assets
|
||||
- [ ] Create query use cases for frontend data
|
||||
- [ ] Update tests to use proper patterns
|
||||
- [ ] Verify no singleton usage anywhere
|
||||
- [ ] Verify all services are pure domain services
|
||||
- [ ] Verify all persistence is in repositories
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary
|
||||
|
||||
The current implementation violates Clean Architecture by:
|
||||
1. Using singletons for state management
|
||||
2. Placing services in adapters layer
|
||||
3. Hardcoding data sources instead of using repositories
|
||||
4. Mixing persistence logic with business logic
|
||||
|
||||
The solution requires:
|
||||
1. **Removing** all singleton stores and in-memory services
|
||||
2. **Creating** proper domain services that compute from real data
|
||||
3. **Implementing** repository pattern for all persistence
|
||||
4. **Updating** seed logic to compute stats from results/standings
|
||||
5. **Adding** media repository for frontend assets
|
||||
6. **Using** CQRS pattern for queries
|
||||
|
||||
This will result in a clean, maintainable, and scalable architecture that properly follows Clean Architecture principles.
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2025-12-29
|
||||
**Status**: Planning Phase
|
||||
**Next Steps**: Implementation
|
||||
@@ -1,115 +0,0 @@
|
||||
# Comprehensive Seeding Plan for GridPilot
|
||||
|
||||
## Current Seeding Setup
|
||||
Current seeding in [`adapters/bootstrap`](adapters/bootstrap) includes:
|
||||
- `EnsureInitialData.ts`: Creates admin user (`admin@gridpilot.local` / `admin123`) and all achievements (driver, steward, admin, community).
|
||||
- `SeedRacingData.ts`: If no drivers exist, seeds ~100 drivers, 20 leagues, seasons, teams, races, results, standings, memberships, join requests, protests, penalties, sponsors, wallets/transactions, social feed/friendships using factories in `bootstrap/racing/`.
|
||||
|
||||
Seeding skips if data exists (idempotent), uses seed IDs for determinism (e.g., `seedId('league-1', persistence)`).
|
||||
|
||||
Persistence-aware (inmemory/postgres), ensures scoring configs for existing data.
|
||||
|
||||
## Identified Entities
|
||||
From `core/*/domain/entities/` and factories/repositories:
|
||||
|
||||
### Identity Domain
|
||||
- [`User`](core/identity/domain/entities/User.ts)
|
||||
- [`Achievement`](core/identity/domain/entities/Achievement.ts)
|
||||
- [`UserAchievement`](core/identity/domain/entities/UserAchievement.ts)
|
||||
- [`SponsorAccount`](core/identity/domain/entities/SponsorAccount.ts)
|
||||
- [`ExternalGameRatingProfile`](core/identity/domain/entities/ExternalGameRatingProfile.ts)
|
||||
- [`RatingEvent`](core/identity/domain/entities/RatingEvent.ts)
|
||||
- [`AdminVoteSession`](core/identity/domain/entities/AdminVoteSession.ts)
|
||||
|
||||
### Racing Domain (primary, most seeded)
|
||||
- [`Driver`](core/racing/domain/entities/Driver.ts): id, iracingId, name, country, bio?, joinedAt
|
||||
- [`League`](core/racing/domain/entities/League.ts): id, name, description, ownerId, settings (pointsSystem enum ['f1-2024','indycar','custom'], sessionDuration, qualifyingFormat enum, maxDrivers, visibility enum ['ranked','unranked'], stewarding config), createdAt, socialLinks?, participantCount (0-max)
|
||||
- [`Season`](core/racing/domain/entities/season/Season.ts): id, leagueId, gameId, name, year?, order?, status enum ['planned','active','completed','archived','cancelled'], start/endDate?, schedule?, schedulePublished bool, scoringConfig?, dropPolicy?, stewardingConfig?, maxDrivers?, participantCount
|
||||
- [`Team`](core/racing/domain/entities/Team.ts): id, name, tag, description, ownerId (DriverId), leagues[], createdAt
|
||||
- [`Standing`](core/racing/domain/entities/Standing.ts): id (leagueId:driverId), leagueId, driverId, points, wins, position, racesCompleted
|
||||
- Race-related: [`Race`](core/racing/domain/entities/Race.ts), [`RaceEvent`](core/racing/domain/entities/RaceEvent.ts), [`Session`](core/racing/domain/entities/Session.ts), [`Result`](core/racing/domain/entities/result/Result.ts), [`RaceRegistration`](core/racing/domain/entities/RaceRegistration.ts)
|
||||
- Stewarding: [`Protest`](core/racing/domain/entities/Protest.ts) (statuses), [`Penalty`](core/racing/domain/entities/penalty/Penalty.ts) (types enum, status)
|
||||
- Other: [`JoinRequest`](core/racing/domain/entities/JoinRequest.ts), [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts), [`Sponsor`](core/racing/domain/entities/sponsor/Sponsor.ts), [`SeasonSponsorship`](core/racing/domain/entities/season/SeasonSponsorship.ts), [`LeagueWallet`](core/racing/domain/entities/league-wallet/LeagueWallet.ts), [`Transaction`](core/racing/domain/entities/league-wallet/Transaction.ts), Track, Car, etc.
|
||||
- ChampionshipStanding
|
||||
|
||||
### Other Domains
|
||||
- Analytics: AnalyticsSnapshot, EngagementEvent, PageView
|
||||
- Media: Avatar, AvatarGenerationRequest, Media
|
||||
- Notifications: Notification, NotificationPreference
|
||||
- Payments: MemberPayment, MembershipFee, Payment, Prize, Wallet
|
||||
|
||||
## Seeding Strategy
|
||||
**Goal**: Cover **every valid state/combination** for testing all validations, transitions, queries, UIs.
|
||||
- Use factories extending current `Racing*Factory` pattern.
|
||||
- Deterministic IDs via `seedId(name, persistence)`.
|
||||
- Group by domain, vary enums/states systematically.
|
||||
- Relations: Create minimal graphs covering with/without relations.
|
||||
- Volume: 5-20 examples per entity, prioritizing edge cases (min/max, empty/full, all enum values).
|
||||
- Idempotent: Check existence before create.
|
||||
|
||||
### 1. Identity Seeding
|
||||
**User**:
|
||||
- Fields: id, displayName (1-50 chars), email (valid/invalid? but valid), passwordHash, iracingCustomerId?, primaryDriverId?, avatarUrl?
|
||||
- States: 5 users - no email, with iRacing linked, with primaryDriver, admin@gridpilot.local (existing), verified/unverified (if state).
|
||||
- Examples:
|
||||
- Admin: id='user-admin', displayName='Admin', email='admin@gridpilot.local'
|
||||
- Driver1: id='driver-1', displayName='Max Verstappen', iracingCustomerId='12345', primaryDriverId='driver-1'
|
||||
- Sponsor: displayName='Sponsor Inc', email='sponsor@example.com'
|
||||
|
||||
**Achievement** etc.: All constants already seeded, add user-achievements linking users to achievements.
|
||||
|
||||
### 2. Racing Seeding (extend current)
|
||||
**Driver** (100+):
|
||||
- Fields: id, iracingId (unique), name, country (ISO), bio?, joinedAt
|
||||
- States: 20 countries, bio empty/full, recent/past joined.
|
||||
- Relations: Link to users, teams.
|
||||
|
||||
**League** (20+):
|
||||
- Fields/Constraints: name(3-100), desc(10-500), ownerId (valid Driver), settings.pointsSystem (3 enums), sessionDuration(15-240min), qualifyingFormat(2), maxDrivers(10-60), visibility(2, ranked min10 participants?), stewarding.decisionMode(6 enums), requiredVotes(1-10), timeLimits(1-168h), participantCount(0-max)
|
||||
- All combos: 3 points x 2 qual x 2 vis x 6 decision = ~72, but sample 20 covering extremes.
|
||||
- States:
|
||||
- Empty new league (participantCount=0)
|
||||
- Full ranked (maxDrivers=40, count=40)
|
||||
- Unranked small (max=8, count=5)
|
||||
- Various stewarding (admin_only, steward_vote req=3, etc.)
|
||||
- Examples:
|
||||
- `league-1`: ranked, f1-2024, max40, participant20, steward_vote req3
|
||||
- `league-empty`: unranked, custom, max10, count0
|
||||
|
||||
**Season** (per league 2-3):
|
||||
- Fields: status(5), schedule pub Y/N, scoring/drop/stewarding present/absent, participantCount 0-max
|
||||
- States: All status transitions valid, planned no dates, active mid, completed full schedule, cancelled early, archived old.
|
||||
- Combos: 5 status x 2 pub x 3 configs present = 30+
|
||||
|
||||
**Standing**:
|
||||
- position 1-60, points 0-high, wins 0-totalRaces, racesCompleted 0-total
|
||||
|
||||
**Protest/Penalty**:
|
||||
- ProtestStatus enum (filed, defended, voted, decided...), IncidentDescription, etc.
|
||||
- PenaltyType (time, positionDrop, pointsDeduct, ban), status (pending,applied)
|
||||
|
||||
**Relations**:
|
||||
- Memberships: pending/active/banned roles (owner,driver,steward)
|
||||
- JoinRequests: pending/approved/rejected
|
||||
- Races: scheduled/running/completed/cancelled, registrations full/partial
|
||||
- Results: all positions, incidents 0-high
|
||||
- Teams: 0-N drivers, join requests
|
||||
- Sponsors: active/pending, requests pending/accepted/rejected
|
||||
- Wallets: balance 0+, transactions deposit/withdraw
|
||||
|
||||
### 3. Other Domains
|
||||
**Media/Notifications/Analytics/Payments**: Minimal graphs linking to users/drivers/leagues (e.g. avatars for drivers, notifications for joins, pageviews for leagues, payments for memberships).
|
||||
|
||||
## Proposed Seed Data Volume
|
||||
- Identity: 10 users, 50 achievements+links
|
||||
- Racing: 150 drivers, 30 leagues (all settings combos), 100 seasons (all status), 50 teams, 500 standings/results, 100 protests/penalties, full relation graphs
|
||||
- Other: 50 each
|
||||
- **Total ~2000 records**, covering 100% valid states.
|
||||
|
||||
## Implementation Steps (for Code mode)
|
||||
1. Extend factories for new states/combos.
|
||||
2. Add factories for non-racing entities.
|
||||
3. Update SeedRacingData to call all.
|
||||
4. EnsureInitialData for non-racing.
|
||||
|
||||
This plan covers **every single possible valid state** via systematic enum cartesian + edges (0/max/empty/full/pending/complete)."
|
||||
@@ -1,114 +0,0 @@
|
||||
# State Management Consolidation Plan
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Current Mess
|
||||
The codebase has **multiple competing state management solutions** causing confusion and redundancy:
|
||||
|
||||
#### 1. `/apps/website/components/shared/state/` (The "New" Solution)
|
||||
- **StateContainer.tsx** - Combined wrapper for all states (loading, error, empty, success)
|
||||
- **LoadingWrapper.tsx** - 5 variants: spinner, skeleton, full-screen, inline, card
|
||||
- **ErrorDisplay.tsx** - 4 variants: full-screen, inline, card, toast
|
||||
- **EmptyState.tsx** - 3 variants + pre-configured states + illustrations
|
||||
- **❌ Missing types file**: `../types/state.types` (deleted but still imported)
|
||||
|
||||
#### 2. `/apps/website/components/shared/` (Legacy Simple Components)
|
||||
- **EmptyState.tsx** - Simple empty state (39 lines)
|
||||
- **LoadingState.tsx** - Simple loading spinner (15 lines)
|
||||
|
||||
#### 3. `/apps/website/components/errors/` (Alternative Error Solutions)
|
||||
- **ErrorDisplay.tsx** - API error focused (different API than shared/state)
|
||||
- **EnhancedErrorBoundary.tsx** - React error boundary
|
||||
- **ApiErrorBoundary.tsx** - API error boundary
|
||||
- **EnhancedFormError.tsx** - Form error handling
|
||||
|
||||
#### 4. Domain-Specific Solutions
|
||||
- **FeedEmptyState.tsx** - Feed-specific empty state
|
||||
- **EmptyState.tsx** in leagues - Different design system
|
||||
|
||||
### Core Issues
|
||||
1. **Missing types file** causing import errors in all state components
|
||||
2. **Multiple similar components** with different APIs
|
||||
3. **Inconsistent naming patterns**
|
||||
4. **Redundant functionality**
|
||||
5. **No single source of truth**
|
||||
6. **Mixed concerns** between generic and domain-specific
|
||||
|
||||
## Solution Strategy
|
||||
|
||||
### Phase 1: Create Unified Types
|
||||
Create `apps/website/components/shared/state/types.ts` with all type definitions:
|
||||
- EmptyState types
|
||||
- LoadingState types
|
||||
- ErrorDisplay types
|
||||
- StateContainer types
|
||||
- Convenience prop types
|
||||
|
||||
### Phase 2: Consolidate Components
|
||||
Keep only the comprehensive solution in `/apps/website/components/shared/state/`:
|
||||
- Update imports to use new types file
|
||||
- Ensure all components work with unified types
|
||||
- Remove any redundant internal type definitions
|
||||
|
||||
### Phase 3: Remove Redundant Files
|
||||
Delete:
|
||||
- `/apps/website/components/shared/EmptyState.tsx` (legacy)
|
||||
- `/apps/website/components/shared/LoadingState.tsx` (legacy)
|
||||
- Keep domain-specific ones if they serve unique purposes
|
||||
|
||||
### Phase 4: Update All Imports
|
||||
Find and update all imports across the codebase to use the consolidated solution.
|
||||
|
||||
## Detailed Implementation
|
||||
|
||||
### Step 1: Create Types File
|
||||
**File**: `apps/website/components/shared/state/types.ts`
|
||||
|
||||
```typescript
|
||||
// All type definitions for state management
|
||||
// EmptyState, LoadingWrapper, ErrorDisplay, StateContainer props
|
||||
// Plus convenience types
|
||||
```
|
||||
|
||||
### Step 2: Update State Components
|
||||
**Files to update**:
|
||||
- `EmptyState.tsx` - Import from `./types.ts`
|
||||
- `LoadingWrapper.tsx` - Import from `./types.ts`
|
||||
- `ErrorDisplay.tsx` - Import from `./types.ts`
|
||||
- `StateContainer.tsx` - Import from `./types.ts`
|
||||
|
||||
### Step 3: Remove Legacy Files
|
||||
**Delete**:
|
||||
- `apps/website/components/shared/EmptyState.tsx`
|
||||
- `apps/website/components/shared/LoadingState.tsx`
|
||||
|
||||
### Step 4: Update External Imports
|
||||
**Search for**: `from '@/components/shared/EmptyState'` or `from '@/components/shared/LoadingState'`
|
||||
**Replace with**: `from '@/components/shared/state/EmptyState'` or `from '@/components/shared/state/LoadingWrapper'`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Create:
|
||||
1. `apps/website/components/shared/state/types.ts`
|
||||
|
||||
### Update:
|
||||
1. `apps/website/components/shared/state/EmptyState.tsx`
|
||||
2. `apps/website/components/shared/state/LoadingWrapper.tsx`
|
||||
3. `apps/website/components/shared/state/ErrorDisplay.tsx`
|
||||
4. `apps/website/components/shared/state/StateContainer.tsx`
|
||||
|
||||
### Delete:
|
||||
1. `apps/website/components/shared/EmptyState.tsx`
|
||||
2. `apps/website/components/shared/LoadingState.tsx`
|
||||
|
||||
### Update Imports in:
|
||||
- Multiple app pages and templates
|
||||
- Components that use the old paths
|
||||
|
||||
## Expected Benefits
|
||||
1. ✅ Single source of truth for state types
|
||||
2. ✅ Consistent API across all state components
|
||||
3. ✅ No import errors
|
||||
4. ✅ Reduced file count and complexity
|
||||
5. ✅ Better maintainability
|
||||
6. ✅ Clear separation between generic and domain-specific solutions
|
||||
@@ -1,52 +0,0 @@
|
||||
# Team logos wrong after force reseed: TDD plan
|
||||
|
||||
## Observed runtime failure
|
||||
- Browser console: Internal server error.
|
||||
- Team images still not shown.
|
||||
|
||||
## Hypothesis
|
||||
The current force-reseed cleanup in [`SeedRacingData.clearExistingRacingData()`](adapters/bootstrap/SeedRacingData.ts:479) still leaves inconsistent DB state (or errors during deletion), so seeding or the teams endpoint fails.
|
||||
|
||||
## Approach (TDD)
|
||||
|
||||
### 1) Reproduce via HTTP
|
||||
- Use curl against `/teams/all` and capture:
|
||||
- HTTP status
|
||||
- response body
|
||||
- server logs correlating to request
|
||||
|
||||
### 2) Capture docker logs around bootstrap
|
||||
- Start/ensure dev stack is up via [`docker-compose.dev.yml`](docker-compose.dev.yml:1).
|
||||
- Collect:
|
||||
- API logs from container startup through seeding
|
||||
- DB logs if errors/constraint violations occur
|
||||
|
||||
### 3) Add regression test (make it fail first)
|
||||
- Add an API e2e/integration test that:
|
||||
1. Runs with postgres persistence and force reseed on.
|
||||
2. Calls `/teams/all`.
|
||||
3. Asserts every team returns a generated logo URL:
|
||||
- `logoUrl` matches `/media/teams/{id}/logo` (or resolver output for generated ref)
|
||||
- must not be `/media/default/logo.png`
|
||||
|
||||
Candidate location: existing media module tests under [`apps/api/src/domain/media`](apps/api/src/domain/media/MediaModule.test.ts:1) or a new teams controller test.
|
||||
|
||||
### 4) Diagnose failing test
|
||||
- If 500:
|
||||
- Identify stack trace and failing query.
|
||||
- Confirm whether failures occur during reseed or request handling.
|
||||
- If 200 but wrong URLs:
|
||||
- Query DB for `racing_teams.logoRef` and verify it is generated.
|
||||
|
||||
### 5) Minimal fix
|
||||
Prefer fixing cleanup by:
|
||||
- Deleting in correct order to satisfy FKs.
|
||||
- Ensuring `racing_teams` + dependent tables are cleared.
|
||||
- Avoiding partial deletes that can leave orphaned rows.
|
||||
|
||||
### 6) Verification
|
||||
- Run eslint, tsc, tests.
|
||||
- Manual verification:
|
||||
- `curl http://localhost:3001/teams/all` returns `logoUrl: /media/teams/{id}/logo`.
|
||||
- Requesting one returned URL is `200 OK`.
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# Type Inventory & Classification
|
||||
|
||||
## Overview
|
||||
This document inventories all types extracted from `apps/website/lib/apiClient.ts` (lines 13-634), classifies them according to clean architecture principles, and specifies their target locations.
|
||||
|
||||
## Classification Rules
|
||||
- **DTOs**: Transport objects from API, pure data, no methods
|
||||
- **ViewModels**: UI-specific with computed properties, formatting, display logic
|
||||
- **Input DTOs**: Request parameters for API calls
|
||||
- **Output DTOs**: API response wrappers
|
||||
|
||||
## Types from apiClient.ts
|
||||
|
||||
| Type Name | Line Range | Classification | Target Location | Dependencies | Used By |
|
||||
|-----------|------------|----------------|-----------------|--------------|---------|
|
||||
| DriverDTO | 13-19 | DTO | apps/website/lib/dtos/DriverDto.ts | none | Various pages (driver profiles, leaderboards, etc.) |
|
||||
| ProtestViewModel | 21-29 | ViewModel | apps/website/lib/viewModels/ProtestViewModel.ts | none | League stewarding pages |
|
||||
| LeagueMemberViewModel | 31-36 | ViewModel | apps/website/lib/viewModels/LeagueMemberViewModel.ts | DriverDTO | League member lists |
|
||||
| StandingEntryViewModel | 38-46 | ViewModel | apps/website/lib/viewModels/StandingEntryViewModel.ts | DriverDTO | League standings pages |
|
||||
| ScheduledRaceViewModel | 48-54 | ViewModel | apps/website/lib/viewModels/ScheduledRaceViewModel.ts | none | League schedule pages |
|
||||
| LeagueSummaryViewModel | 57-70 | ViewModel | apps/website/lib/viewModels/LeagueSummaryViewModel.ts | none | League lists, dashboards |
|
||||
| AllLeaguesWithCapacityViewModel | 72-74 | ViewModel | apps/website/lib/viewModels/AllLeaguesWithCapacityViewModel.ts | LeagueSummaryViewModel | League discovery pages |
|
||||
| LeagueStatsDto | 76-78 | DTO | apps/website/lib/dtos/LeagueStatsDto.ts | none | League stats displays |
|
||||
| LeagueJoinRequestViewModel | 80-86 | ViewModel | apps/website/lib/viewModels/LeagueJoinRequestViewModel.ts | none | League admin join requests |
|
||||
| LeagueAdminPermissionsViewModel | 88-95 | ViewModel | apps/website/lib/viewModels/LeagueAdminPermissionsViewModel.ts | none | League admin checks |
|
||||
| LeagueOwnerSummaryViewModel | 97-102 | ViewModel | apps/website/lib/viewModels/LeagueOwnerSummaryViewModel.ts | none | League owner dashboards |
|
||||
| LeagueConfigFormModelDto | 104-111 | DTO | apps/website/lib/dtos/LeagueConfigFormModelDto.ts | none | League configuration forms |
|
||||
| LeagueAdminProtestsViewModel | 113-115 | ViewModel | apps/website/lib/viewModels/LeagueAdminProtestsViewModel.ts | ProtestViewModel | League protest admin |
|
||||
| LeagueSeasonSummaryViewModel | 117-123 | ViewModel | apps/website/lib/viewModels/LeagueSeasonSummaryViewModel.ts | none | League season lists |
|
||||
| LeagueMembershipsViewModel | 125-127 | ViewModel | apps/website/lib/viewModels/LeagueMembershipsViewModel.ts | LeagueMemberViewModel | League member management |
|
||||
| LeagueStandingsViewModel | 129-131 | ViewModel | apps/website/lib/viewModels/LeagueStandingsViewModel.ts | StandingEntryViewModel | League standings pages |
|
||||
| LeagueScheduleViewModel | 133-135 | ViewModel | apps/website/lib/viewModels/LeagueScheduleViewModel.ts | ScheduledRaceViewModel | League schedule pages |
|
||||
| LeagueStatsViewModel | 137-145 | ViewModel | apps/website/lib/viewModels/LeagueStatsViewModel.ts | none | League stats pages |
|
||||
| LeagueAdminViewModel | 147-151 | ViewModel | apps/website/lib/viewModels/LeagueAdminViewModel.ts | LeagueConfigFormModelDto, LeagueMemberViewModel, LeagueJoinRequestViewModel | League admin pages |
|
||||
| CreateLeagueInput | 153-159 | Input DTO | apps/website/lib/dtos/CreateLeagueInputDto.ts | none | League creation forms |
|
||||
| CreateLeagueOutput | 161-164 | Output DTO | apps/website/lib/dtos/CreateLeagueOutputDto.ts | none | League creation responses |
|
||||
| DriverLeaderboardItemViewModel | 167-175 | ViewModel | apps/website/lib/viewModels/DriverLeaderboardItemViewModel.ts | none | Driver leaderboards |
|
||||
| DriversLeaderboardViewModel | 177-179 | ViewModel | apps/website/lib/viewModels/DriversLeaderboardViewModel.ts | DriverLeaderboardItemViewModel | Driver leaderboard pages |
|
||||
| DriverStatsDto | 181-183 | DTO | apps/website/lib/dtos/DriverStatsDto.ts | none | Driver stats displays |
|
||||
| CompleteOnboardingInput | 185-188 | Input DTO | apps/website/lib/dtos/CompleteOnboardingInputDto.ts | none | Driver onboarding |
|
||||
| CompleteOnboardingOutput | 190-193 | Output DTO | apps/website/lib/dtos/CompleteOnboardingOutputDto.ts | none | Driver onboarding responses |
|
||||
| DriverRegistrationStatusViewModel | 195-199 | ViewModel | apps/website/lib/viewModels/DriverRegistrationStatusViewModel.ts | none | Race registration status |
|
||||
| TeamSummaryViewModel | 202-208 | ViewModel | apps/website/lib/viewModels/TeamSummaryViewModel.ts | none | Team lists |
|
||||
| AllTeamsViewModel | 210-212 | ViewModel | apps/website/lib/viewModels/AllTeamsViewModel.ts | TeamSummaryViewModel | Team discovery pages |
|
||||
| TeamMemberViewModel | 214-219 | ViewModel | apps/website/lib/viewModels/TeamMemberViewModel.ts | DriverDTO | Team member lists |
|
||||
| TeamJoinRequestItemViewModel | 221-227 | ViewModel | apps/website/lib/viewModels/TeamJoinRequestItemViewModel.ts | none | Team join requests |
|
||||
| TeamDetailsViewModel | 229-237 | ViewModel | apps/website/lib/viewModels/TeamDetailsViewModel.ts | TeamMemberViewModel | Team detail pages |
|
||||
| TeamMembersViewModel | 239-241 | ViewModel | apps/website/lib/viewModels/TeamMembersViewModel.ts | TeamMemberViewModel | Team member pages |
|
||||
| TeamJoinRequestsViewModel | 243-245 | ViewModel | apps/website/lib/viewModels/TeamJoinRequestsViewModel.ts | TeamJoinRequestItemViewModel | Team join request pages |
|
||||
| DriverTeamViewModel | 247-252 | ViewModel | apps/website/lib/viewModels/DriverTeamViewModel.ts | none | Driver team info |
|
||||
| CreateTeamInput | 254-259 | Input DTO | apps/website/lib/dtos/CreateTeamInputDto.ts | none | Team creation forms |
|
||||
| CreateTeamOutput | 261-263 | Output DTO | apps/website/lib/dtos/CreateTeamOutputDto.ts | none | Team creation responses |
|
||||
| UpdateTeamInput | 265-270 | Input DTO | apps/website/lib/dtos/UpdateTeamInputDto.ts | none | Team update forms |
|
||||
| UpdateTeamOutput | 271-273 | Output DTO | apps/website/lib/dtos/UpdateTeamOutputDto.ts | none | Team update responses |
|
||||
| RaceListItemViewModel | 275-284 | ViewModel | apps/website/lib/viewModels/RaceListItemViewModel.ts | none | Race lists |
|
||||
| AllRacesPageViewModel | 286-288 | ViewModel | apps/website/lib/viewModels/AllRacesPageViewModel.ts | RaceListItemViewModel | Race discovery pages |
|
||||
| RaceStatsDto | 290-292 | DTO | apps/website/lib/dtos/RaceStatsDto.ts | none | Race stats displays |
|
||||
| RaceDetailEntryViewModel | 294-302 | ViewModel | apps/website/lib/viewModels/RaceDetailEntryViewModel.ts | none | Race detail entry lists |
|
||||
| RaceDetailUserResultViewModel | 304-313 | ViewModel | apps/website/lib/viewModels/RaceDetailUserResultViewModel.ts | none | Race detail user results |
|
||||
| RaceDetailRaceViewModel | 315-326 | ViewModel | apps/website/lib/viewModels/RaceDetailRaceViewModel.ts | none | Race detail race info |
|
||||
| RaceDetailLeagueViewModel | 328-336 | ViewModel | apps/website/lib/viewModels/RaceDetailLeagueViewModel.ts | none | Race detail league info |
|
||||
| RaceDetailRegistrationViewModel | 338-341 | ViewModel | apps/website/lib/viewModels/RaceDetailRegistrationViewModel.ts | none | Race registration status |
|
||||
| RaceDetailViewModel | 343-350 | ViewModel | apps/website/lib/viewModels/RaceDetailViewModel.ts | RaceDetailRaceViewModel, RaceDetailLeagueViewModel, RaceDetailEntryViewModel, RaceDetailRegistrationViewModel, RaceDetailUserResultViewModel | Race detail pages |
|
||||
| RacesPageDataRaceViewModel | 352-364 | ViewModel | apps/website/lib/viewModels/RacesPageDataRaceViewModel.ts | none | Races page data |
|
||||
| RacesPageDataViewModel | 366-368 | ViewModel | apps/website/lib/viewModels/RacesPageDataViewModel.ts | RacesPageDataRaceViewModel | Races page |
|
||||
| RaceResultViewModel | 370-381 | ViewModel | apps/website/lib/viewModels/RaceResultViewModel.ts | none | Race results |
|
||||
| RaceResultsDetailViewModel | 383-387 | ViewModel | apps/website/lib/viewModels/RaceResultsDetailViewModel.ts | RaceResultViewModel | Race results detail |
|
||||
| RaceWithSOFViewModel | 389-393 | ViewModel | apps/website/lib/viewModels/RaceWithSOFViewModel.ts | none | Race SOF displays |
|
||||
| RaceProtestViewModel | 395-405 | ViewModel | apps/website/lib/viewModels/RaceProtestViewModel.ts | none | Race protests |
|
||||
| RaceProtestsViewModel | 407-410 | ViewModel | apps/website/lib/viewModels/RaceProtestsViewModel.ts | RaceProtestViewModel | Race protests list |
|
||||
| RacePenaltyViewModel | 412-421 | ViewModel | apps/website/lib/viewModels/RacePenaltyViewModel.ts | none | Race penalties |
|
||||
| RacePenaltiesViewModel | 423-426 | ViewModel | apps/website/lib/viewModels/RacePenaltiesViewModel.ts | RacePenaltyViewModel | Race penalties list |
|
||||
| RegisterForRaceParams | 428-431 | Input DTO | apps/website/lib/dtos/RegisterForRaceParamsDto.ts | none | Race registration |
|
||||
| WithdrawFromRaceParams | 433-435 | Input DTO | apps/website/lib/dtos/WithdrawFromRaceParamsDto.ts | none | Race withdrawal |
|
||||
| ImportRaceResultsInput | 437-440 | Input DTO | apps/website/lib/dtos/ImportRaceResultsInputDto.ts | none | Race results import |
|
||||
| ImportRaceResultsSummaryViewModel | 441-447 | ViewModel | apps/website/lib/viewModels/ImportRaceResultsSummaryViewModel.ts | none | Import results summary |
|
||||
| GetEntitySponsorshipPricingResultDto | 450-454 | DTO | apps/website/lib/dtos/GetEntitySponsorshipPricingResultDto.ts | none | Sponsorship pricing |
|
||||
| SponsorViewModel | 456-461 | ViewModel | apps/website/lib/viewModels/SponsorViewModel.ts | none | Sponsor lists |
|
||||
| GetSponsorsOutput | 463-465 | Output DTO | apps/website/lib/dtos/GetSponsorsOutputDto.ts | SponsorViewModel | Sponsor list responses |
|
||||
| CreateSponsorInput | 467-472 | Input DTO | apps/website/lib/dtos/CreateSponsorInputDto.ts | none | Sponsor creation |
|
||||
| CreateSponsorOutput | 474-477 | Output DTO | apps/website/lib/dtos/CreateSponsorOutputDto.ts | none | Sponsor creation responses |
|
||||
| SponsorDashboardDTO | 479-485 | DTO | apps/website/lib/dtos/SponsorDashboardDto.ts | none | Sponsor dashboards |
|
||||
| SponsorshipDetailViewModel | 487-496 | ViewModel | apps/website/lib/viewModels/SponsorshipDetailViewModel.ts | none | Sponsorship details |
|
||||
| SponsorSponsorshipsDTO | 498-502 | DTO | apps/website/lib/dtos/SponsorSponsorshipsDto.ts | SponsorshipDetailViewModel | Sponsor sponsorships |
|
||||
| RequestAvatarGenerationInput | 505-508 | Input DTO | apps/website/lib/dtos/RequestAvatarGenerationInputDto.ts | none | Avatar generation requests |
|
||||
| RequestAvatarGenerationOutput | 510-514 | Output DTO | apps/website/lib/dtos/RequestAvatarGenerationOutputDto.ts | none | Avatar generation responses |
|
||||
| RecordPageViewInput | 517-521 | Input DTO | apps/website/lib/dtos/RecordPageViewInputDto.ts | none | Page view tracking |
|
||||
| RecordPageViewOutput | 523-525 | Output DTO | apps/website/lib/dtos/RecordPageViewOutputDto.ts | none | Page view responses |
|
||||
| RecordEngagementInput | 527-532 | Input DTO | apps/website/lib/dtos/RecordEngagementInputDto.ts | none | Engagement tracking |
|
||||
| RecordEngagementOutput | 534-536 | Output DTO | apps/website/lib/dtos/RecordEngagementOutputDto.ts | none | Engagement responses |
|
||||
| LoginParams | 539-542 | Input DTO | apps/website/lib/dtos/LoginParamsDto.ts | none | Login forms |
|
||||
| SignupParams | 544-548 | Input DTO | apps/website/lib/dtos/SignupParamsDto.ts | none | Signup forms |
|
||||
| SessionData | 550-556 | DTO | apps/website/lib/dtos/SessionDataDto.ts | none | Session management |
|
||||
| PaymentViewModel | 559-565 | ViewModel | apps/website/lib/viewModels/PaymentViewModel.ts | none | Payment lists |
|
||||
| GetPaymentsOutput | 567-569 | Output DTO | apps/website/lib/dtos/GetPaymentsOutputDto.ts | PaymentViewModel | Payment list responses |
|
||||
| CreatePaymentInput | 571-577 | Input DTO | apps/website/lib/dtos/CreatePaymentInputDto.ts | none | Payment creation |
|
||||
| CreatePaymentOutput | 579-582 | Output DTO | apps/website/lib/dtos/CreatePaymentOutputDto.ts | none | Payment creation responses |
|
||||
| MembershipFeeViewModel | 584-589 | ViewModel | apps/website/lib/viewModels/MembershipFeeViewModel.ts | none | Membership fees |
|
||||
| MemberPaymentViewModel | 591-596 | ViewModel | apps/website/lib/viewModels/MemberPaymentViewModel.ts | none | Member payments |
|
||||
| GetMembershipFeesOutput | 598-601 | Output DTO | apps/website/lib/dtos/GetMembershipFeesOutputDto.ts | MembershipFeeViewModel, MemberPaymentViewModel | Membership fees responses |
|
||||
| PrizeViewModel | 603-609 | ViewModel | apps/website/lib/viewModels/PrizeViewModel.ts | none | Prize lists |
|
||||
| GetPrizesOutput | 611-613 | Output DTO | apps/website/lib/dtos/GetPrizesOutputDto.ts | PrizeViewModel | Prize list responses |
|
||||
| WalletTransactionViewModel | 615-621 | ViewModel | apps/website/lib/viewModels/WalletTransactionViewModel.ts | none | Wallet transactions |
|
||||
| WalletViewModel | 623-628 | ViewModel | apps/website/lib/viewModels/WalletViewModel.ts | WalletTransactionViewModel | Wallet displays |
|
||||
| GetWalletOutput | 630-632 | Output DTO | apps/website/lib/dtos/GetWalletOutputDto.ts | WalletViewModel | Wallet responses |
|
||||
|
||||
## Inline DTOs Found in Pages
|
||||
|
||||
### apps/website/app/races/[id]/results/page.tsx (lines 17-56)
|
||||
- **PenaltyTypeDTO** (17-24): Union type for penalty types - should be extracted to DTO
|
||||
- **PenaltyData** (26-30): Interface for penalty data - should be extracted to DTO
|
||||
- **RaceResultRowDTO** (32-41): Interface with method - should be extracted to ViewModel (has getPositionChange method)
|
||||
- **DriverRowDTO** (43-46): Simple driver data - should be extracted to DTO
|
||||
- **ImportResultRowDTO** (48-56): Import result data - should be extracted to DTO
|
||||
|
||||
### Other Inline Types (mostly local component types, not for extraction)
|
||||
- Various filter types, component props interfaces, and local UI types that are not API-related DTOs/ViewModels
|
||||
|
||||
## Dependencies Mapping
|
||||
- DriverDTO: Used by LeagueMemberViewModel, StandingEntryViewModel, TeamMemberViewModel
|
||||
- LeagueSummaryViewModel: Used by AllLeaguesWithCapacityViewModel
|
||||
- DriverLeaderboardItemViewModel: Used by DriversLeaderboardViewModel
|
||||
- TeamSummaryViewModel: Used by AllTeamsViewModel
|
||||
- TeamMemberViewModel: Used by TeamDetailsViewModel, TeamMembersViewModel
|
||||
- TeamJoinRequestItemViewModel: Used by TeamJoinRequestsViewModel
|
||||
- RaceListItemViewModel: Used by AllRacesPageViewModel
|
||||
- RaceDetailRaceViewModel, RaceDetailLeagueViewModel, etc.: Used by RaceDetailViewModel
|
||||
- RacesPageDataRaceViewModel: Used by RacesPageDataViewModel
|
||||
- RaceResultViewModel: Used by RaceResultsDetailViewModel
|
||||
- RaceProtestViewModel: Used by RaceProtestsViewModel
|
||||
- RacePenaltyViewModel: Used by RacePenaltiesViewModel
|
||||
- SponsorViewModel: Used by GetSponsorsOutput
|
||||
- SponsorshipDetailViewModel: Used by SponsorSponsorshipsDTO
|
||||
- PaymentViewModel: Used by GetPaymentsOutput
|
||||
- MembershipFeeViewModel, MemberPaymentViewModel: Used by GetMembershipFeesOutput
|
||||
- PrizeViewModel: Used by GetPrizesOutput
|
||||
- WalletTransactionViewModel: Used by WalletViewModel
|
||||
- WalletViewModel: Used by GetWalletOutput
|
||||
|
||||
## Next Steps
|
||||
- Extract all types to their respective files
|
||||
- Update imports in apiClient.ts and all consuming files
|
||||
- Remove inline types from pages and replace with proper imports
|
||||
- Ensure no empty files or mixed DTO/ViewModel files
|
||||
@@ -4,6 +4,542 @@ Scope: `apps/website/**` aligned against `docs/architecture/website/**`, with th
|
||||
|
||||
This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
|
||||
|
||||
This version also includes a concrete remediation plan with file-by-file actions.
|
||||
|
||||
---
|
||||
|
||||
## 0) Target architecture (what good looks like)
|
||||
|
||||
Per-route structure required by [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50):
|
||||
|
||||
```text
|
||||
app/<route>/page.tsx server: composition only
|
||||
lib/page-queries/<Route>... server: fetch + assemble Page DTO + return PageQueryResult
|
||||
app/<route>/<Route>PageClient.tsx client: ViewData creation + client state
|
||||
templates/<Route>Template.tsx client: pure UI, ViewData-only, no computation
|
||||
lib/view-models/** client-only: ViewModels + Presenters (pure)
|
||||
lib/display-objects/** deterministic formatting helpers (no locale APIs)
|
||||
```
|
||||
|
||||
Hard boundaries to enforce:
|
||||
|
||||
- No server code imports from `lib/view-models/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
|
||||
- Templates import neither `lib/view-models/**` nor `lib/display-objects/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
|
||||
- No locale APIs (`Intl.*`, `toLocale*`) in any formatting path (guardrails: [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:13)).
|
||||
- All writes enter via Server Actions (contract: [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)).
|
||||
|
||||
---
|
||||
|
||||
## 1) Remediation plan (explicit actions)
|
||||
|
||||
This is the **minimum actionable work** to align the codebase. It is ordered so earlier steps unblock later ones.
|
||||
|
||||
### 1.1 Block server-side container usage (DI contract)
|
||||
|
||||
**Goal:** zero usages of [`ContainerManager.getInstance().getContainer()`](apps/website/lib/di/container.ts:67) in server execution paths.
|
||||
|
||||
Actions (final standard):
|
||||
|
||||
1. Ban DI from server execution paths entirely.
|
||||
- `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**` MUST NOT use DI.
|
||||
- PageQueries MUST do manual wiring (construct API clients + services explicitly).
|
||||
|
||||
2. Delete or rewrite any helper that makes server DI easy.
|
||||
- Replace any server usage of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) with manual wiring.
|
||||
- Stop calling [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page/PageDataFetcher.ts:21) from server.
|
||||
|
||||
3. Enforcement:
|
||||
- Add a guardrail test/ESLint rule that forbids importing [`ContainerManager`](apps/website/lib/di/container.ts:61) from:
|
||||
- `apps/website/app/**/page.tsx`
|
||||
- `apps/website/lib/page-queries/**`
|
||||
|
||||
Deliverable for this section:
|
||||
|
||||
- A CI-failing rule that prevents future regressions.
|
||||
- A grep for `getContainer()` in server modules returns zero hits.
|
||||
|
||||
### 1.2 Standardize PageQuery contract (one discriminated union)
|
||||
|
||||
**Goal:** all PageQueries return the exact contract described in [`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17).
|
||||
|
||||
Actions:
|
||||
|
||||
1. Define a single `PageQueryResult` type in a shared place under `apps/website/lib/page-queries/**`.
|
||||
2. Update all PageQueries to return:
|
||||
- `ok` with `{ dto }`
|
||||
- `notFound`
|
||||
- `redirect` with `{ to }`
|
||||
- `error` with `{ errorId }` (do not return raw Error objects)
|
||||
|
||||
Example violation:
|
||||
|
||||
- Current local type uses `data`/`destination`: [`PageQueryResult<TData>`](apps/website/lib/page-queries/DashboardPageQuery.ts:11)
|
||||
|
||||
Deliverable:
|
||||
|
||||
- All pages use `switch(result.status)` and call `notFound()` / `redirect()` only from `page.tsx`.
|
||||
|
||||
### 1.3 Fix Template purity (ViewData-only, no imports, no compute)
|
||||
|
||||
**Goal:** Templates become “dumb” renderers: no `useMemo`, no filtering/sorting, no ViewModel or DisplayObject imports.
|
||||
|
||||
Actions:
|
||||
|
||||
1. Remove ViewModel props from Templates.
|
||||
- Example: replace [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19)
|
||||
with `drivers: DriverRankingsViewData[]` (a ViewData type).
|
||||
|
||||
2. Remove compute from Templates.
|
||||
- Example: move [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
|
||||
into:
|
||||
- a ViewModel (client) OR
|
||||
- a Presenter (client) OR
|
||||
- the PageClient container (client) if it is driven by UI state.
|
||||
|
||||
3. Remove DisplayObject usage from Templates.
|
||||
- Example violation: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13)
|
||||
- Replace with primitive values in ViewData (badge label, badge classes).
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Guardrail tests (or ESLint) that fail if any `apps/website/templates/**` imports from:
|
||||
- `apps/website/lib/view-models/**`
|
||||
- `apps/website/lib/display-objects/**`
|
||||
|
||||
### 1.4 Eliminate locale APIs from all formatting paths
|
||||
|
||||
**Goal:** zero usage of `Intl.*` and `toLocale*` in:
|
||||
|
||||
- `apps/website/templates/**`
|
||||
- `apps/website/app/**/page.tsx`
|
||||
- `apps/website/lib/view-models/**`
|
||||
- `apps/website/lib/display-objects/**`
|
||||
- shared formatting helpers like [`time.ts`](apps/website/lib/utilities/time.ts:1)
|
||||
|
||||
Actions:
|
||||
|
||||
1. Replace locale formatting in Templates.
|
||||
- Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148)
|
||||
- Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels.
|
||||
|
||||
2. Replace locale formatting in ViewModels.
|
||||
- Example: [`BillingViewModel.InvoiceViewModel.formattedTotalAmount`](apps/website/lib/view-models/BillingViewModel.ts:85)
|
||||
- Replace with deterministic formatting helpers (no runtime locale).
|
||||
|
||||
3. Remove locale formatting helpers in `lib/utilities`.
|
||||
- Example: [`formatDate()`](apps/website/lib/utilities/time.ts:48)
|
||||
- Replace with deterministic formatters.
|
||||
|
||||
Deliverable:
|
||||
|
||||
- The search pattern `\\bIntl\\.|toLocale` returns zero results for production code.
|
||||
|
||||
### 1.5 Enforce write boundary: Server Actions only
|
||||
|
||||
**Goal:** no client-initiated writes (no `fetch` POST/PUT/PATCH/DELETE from client components).
|
||||
|
||||
Actions:
|
||||
|
||||
1. Replace client logout POST with Server Action.
|
||||
- Example violation: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
|
||||
- Replace with a Server Action and use `<form action={logoutAction}>`.
|
||||
- Ensure the action does the mutation and then triggers navigation and/or revalidation.
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Search for `fetch(` with write methods in client components returns zero hits.
|
||||
|
||||
### 1.6 Remove UX Blockers from services (state leakage risk)
|
||||
|
||||
**Goal:** services remain stateless and safe regardless of DI scope.
|
||||
|
||||
Actions:
|
||||
|
||||
1. Remove or relocate blockers from [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95).
|
||||
- Evidence: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97)
|
||||
2. Re-introduce blockers at the client boundary (component/hook) where they belong.
|
||||
|
||||
Deliverable:
|
||||
|
||||
- No stateful blockers stored on service instances.
|
||||
|
||||
### 1.7 Consolidate hooks into the canonical folder
|
||||
|
||||
**Goal:** one place for React-only helpers: `apps/website/lib/hooks/**`.
|
||||
|
||||
Contract:
|
||||
|
||||
- Canonical placement is `apps/website/lib/hooks/**` ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32), [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217)).
|
||||
|
||||
Actions:
|
||||
|
||||
1. Move `apps/website/hooks/**` → `apps/website/lib/hooks/**`.
|
||||
2. Update imports across `apps/website/**`.
|
||||
3. Remove `hooks/` from TS include list in [`tsconfig.json`](apps/website/tsconfig.json:70).
|
||||
4. Add a guardrail test to fail if `apps/website/hooks/**` reappears.
|
||||
|
||||
### 1.8 Tighten the Service boundary (server-safe only)
|
||||
|
||||
**Goal:** services become safe to call from server modules (Page Queries) with zero ambiguity.
|
||||
|
||||
New non-negotiable rule:
|
||||
|
||||
- `apps/website/lib/services/**` returns **API DTOs or Page DTOs only** (JSON-serializable). It MUST NOT import or instantiate anything from `apps/website/lib/view-models/**`.
|
||||
|
||||
Why (Clean Architecture + SOLID):
|
||||
|
||||
- SRP: services orchestrate IO and composition; they do not prepare UI.
|
||||
- DIP: server modules depend on service interfaces/DTO contracts, not client-only classes.
|
||||
- Eliminates the current “maybe server, maybe client” ambiguity.
|
||||
|
||||
Actions:
|
||||
|
||||
1. Refactor any service returning ViewModels to return DTOs/Page DTOs instead.
|
||||
2. Move mapping/formatting responsibilities into Presenters colocated with ViewModels.
|
||||
3. Add a boundary test: forbid `lib/services/**` importing `lib/view-models/**`.
|
||||
|
||||
### 1.9 Remove `AdminViewModelService` (Presenter misclassified as Service)
|
||||
|
||||
**Goal:** all mapping lives in Presenters, not in services.
|
||||
|
||||
Action:
|
||||
|
||||
- Replace [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) with a Presenter colocated with its ViewModel(s), then delete the service + test.
|
||||
|
||||
### 1.10 Fix the confirmed “PageQuery constructs ViewModel” violation
|
||||
|
||||
Evidence:
|
||||
|
||||
- [`ProfilePageQuery.execute()`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) resolves [`DriverService`](apps/website/lib/page-queries/ProfilePageQuery.ts:3) and then calls `viewModel.toDTO()` ([`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:54)).
|
||||
|
||||
Rule violated:
|
||||
|
||||
- Page Queries MUST NOT instantiate ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)).
|
||||
|
||||
Action:
|
||||
|
||||
- Refactor `DriverService.getDriverProfile()` (and any similar methods) to return Page DTO only when used from server paths.
|
||||
|
||||
---
|
||||
|
||||
## 2) Route-by-route refactor recipes (concrete file actions)
|
||||
|
||||
This section tells you **exactly what to change** for the already-identified hot spots.
|
||||
|
||||
### 2.1 `profile/leagues` route
|
||||
|
||||
Current violations:
|
||||
|
||||
- DI container used on server: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
|
||||
- Inline server “template” inside `page.tsx`: [`ProfileLeaguesTemplate()`](apps/website/app/profile/leagues/page.tsx:82)
|
||||
- Server code manipulates ViewModels: [`LeagueSummaryViewModel`](apps/website/app/profile/leagues/page.tsx:13)
|
||||
|
||||
Required changes:
|
||||
|
||||
1. Create a PageQuery: `apps/website/lib/page-queries/ProfileLeaguesPageQuery.ts`.
|
||||
- It should:
|
||||
- Read session (server-side) and determine `currentDriverId`.
|
||||
- Call API clients directly (manual wiring).
|
||||
- Assemble a **Page DTO** like:
|
||||
- `ownedLeagues: Array<{ leagueId; name; description; membershipRole }>`
|
||||
- `memberLeagues: Array<{ leagueId; name; description; membershipRole }>`
|
||||
- Return `PageQueryResult`.
|
||||
|
||||
2. Replace `apps/website/app/profile/leagues/page.tsx` with composition:
|
||||
- Call PageQuery.
|
||||
- Switch on result.
|
||||
- Render `<ProfileLeaguesPageClient dto={dto} />`.
|
||||
|
||||
3. Create `apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx` (`'use client'`).
|
||||
- Convert Page DTO into ViewData (via Presenter function in `lib/view-models/**`).
|
||||
- Render a pure Template.
|
||||
|
||||
4. Create `apps/website/templates/ProfileLeaguesTemplate.tsx`.
|
||||
- Props: ViewData only.
|
||||
- No API calls. No `useMemo`. No filtering/sorting.
|
||||
|
||||
### 2.2 `teams` routes
|
||||
|
||||
Current violations:
|
||||
|
||||
- Server route does sorting/filtering: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12)
|
||||
- Server route imports ViewModels: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10)
|
||||
- Team detail server route passes ViewModels into Template: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20)
|
||||
- Placeholder “currentDriverId” on server: [`const currentDriverId = ''`](apps/website/app/teams/[id]/page.tsx:63)
|
||||
|
||||
Required changes:
|
||||
|
||||
1. Introduce PageQueries:
|
||||
- `TeamsPageQuery` returns Page DTO `{ teams: Array<{ id; name; rating; ...raw fields }> }`.
|
||||
- `TeamDetailPageQuery` returns Page DTO `{ team; memberships; currentDriverId; }` (raw serializable).
|
||||
|
||||
2. Move derived computations out of server route.
|
||||
- `teamsByLevel`, `topTeams`, etc. belong in client ViewModel or PageClient.
|
||||
|
||||
3. Add `TeamsPageClient.tsx` and `TeamDetailPageClient.tsx`.
|
||||
- Instantiate ViewModels client-side.
|
||||
- Produce ViewData for Templates.
|
||||
|
||||
4. Change Templates to take ViewData.
|
||||
- Stop passing ViewModels into [`TeamDetailTemplate`](apps/website/app/teams/[id]/page.tsx:22).
|
||||
|
||||
### 2.3 `dashboard` PageQuery
|
||||
|
||||
Current violations:
|
||||
|
||||
- Server PageQuery uses singleton DI container: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
|
||||
- PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6)
|
||||
|
||||
Required changes:
|
||||
|
||||
1. Move the DTO-like shape out of view-models and into `lib/page-queries/**`.
|
||||
2. Replace DI with manual wiring.
|
||||
3. Return `PageQueryResult` as spec.
|
||||
4. Ensure the result payload is a Page DTO (raw values only).
|
||||
|
||||
### 2.4 Logout write boundary
|
||||
|
||||
Current violation:
|
||||
|
||||
- Client-side POST request: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
|
||||
|
||||
Required changes:
|
||||
|
||||
1. Create a server action in an appropriate server-only module.
|
||||
2. Replace the demo-user logout handler with a `<form action={logoutAction}>` submission.
|
||||
3. Ensure post-logout navigation uses server-side redirect.
|
||||
|
||||
---
|
||||
|
||||
## 3) What to add to make this enforceable (guardrails)
|
||||
|
||||
Minimum guardrails to add:
|
||||
|
||||
1. ESLint or test guard: fail on `toLocale*` / `Intl.*` under `apps/website/**` formatting paths.
|
||||
2. ESLint or test guard: fail if `apps/website/templates/**` imports ViewModels or Display Objects.
|
||||
3. ESLint or test guard: fail if `apps/website/app/**/page.tsx` imports from `lib/view-models/**`.
|
||||
4. ESLint or test guard: fail if server code imports [`ContainerManager`](apps/website/lib/di/container.ts:61).
|
||||
|
||||
---
|
||||
|
||||
## 4) Missing pieces found in the second scan (add to plan)
|
||||
|
||||
This section is the “coverage pass”: it enumerates additional concrete hotspots that must be refactored using the recipes above.
|
||||
|
||||
### 4.1 Remaining server-side singleton container usage (must be removed)
|
||||
|
||||
1. Home SSR data fetch uses container directly
|
||||
- Evidence: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/services/home/getHomeData.ts:9)
|
||||
- Action: rewrite [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:8) to use explicit wiring (manual construction) or move the logic into a PageQuery that does manual wiring.
|
||||
|
||||
2. Profile page query dynamically imports container and uses singleton
|
||||
- Evidence: `(await import('@/lib/di/container')).ContainerManager.getInstance().getContainer()` in [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
|
||||
- Action: ban all container usage in [`apps/website/lib/page-queries/**`](apps/website/lib/page-queries/DashboardPageQuery.ts:1); refactor to manual wiring.
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Add these files to the “no server DI” guardrail allowlist/banlist:
|
||||
- [`getHomeData.ts`](apps/website/lib/services/home/getHomeData.ts:1)
|
||||
- [`ProfilePageQuery.ts`](apps/website/lib/page-queries/ProfilePageQuery.ts:1)
|
||||
|
||||
### 4.2 Routes still importing ViewModels from server modules (must be converted to Page DTO + PageClient)
|
||||
|
||||
The following are **server route modules** (App Router) that import ViewModels and therefore violate the server/client boundary.
|
||||
|
||||
Actions for each: create a PageQuery returning Page DTO, and a `*PageClient.tsx` that builds ViewData and renders the Template.
|
||||
|
||||
1. Leaderboards entry route
|
||||
- Evidence: ViewModel types imported in [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
|
||||
- Action:
|
||||
- Replace `*PageWrapper` patterns with a `*PageClient` container.
|
||||
- Ensure the template receives ViewData only (no ViewModels).
|
||||
- Remove ViewModel imports from:
|
||||
- [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
|
||||
- [`LeaderboardsPageWrapper.tsx`](apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx:4)
|
||||
|
||||
2. Driver leaderboards route
|
||||
- Evidence: ViewModel import in [`leaderboards/drivers/page.tsx`](apps/website/app/leaderboards/drivers/page.tsx:7)
|
||||
- Action: same as above; rename wrapper to `DriverRankingsPageClient.tsx` and pass ViewData to Templates.
|
||||
|
||||
3. Team leaderboard route
|
||||
- Evidence: ViewModel import in [`teams/leaderboard/page.tsx`](apps/website/app/teams/leaderboard/page.tsx:7) and wrapper import in [`TeamLeaderboardPageWrapper.tsx`](apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx:5)
|
||||
- Action: same as above.
|
||||
|
||||
4. Leagues routes
|
||||
- Evidence:
|
||||
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/page.tsx:13)
|
||||
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/rulebook/page.tsx:13)
|
||||
- [`LeagueScheduleViewModel`](apps/website/app/leagues/[id]/schedule/page.tsx:13)
|
||||
- [`LeagueStandingsViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:13) and [`DriverViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:14)
|
||||
- Action:
|
||||
- Introduce PageQueries per route: `LeagueDetailPageQuery`, `LeagueRulebookPageQuery`, `LeagueSchedulePageQuery`, `LeagueStandingsPageQuery`.
|
||||
- PageQueries return Page DTO only.
|
||||
- Client creates ViewModels + ViewData.
|
||||
|
||||
5. Sponsor routes
|
||||
- Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance: [`AvailableLeaguesViewModel`](apps/website/app/sponsor/leagues/page.tsx:6)
|
||||
- Action:
|
||||
- Convert to PageQuery returning Page DTO.
|
||||
- Move summary computations (counts/averages) to Presenter/ViewModel in client.
|
||||
|
||||
6. Races + stewarding routes
|
||||
- Evidence:
|
||||
- ViewModel imports in [`races/[id]/page.tsx`](apps/website/app/races/[id]/page.tsx:7)
|
||||
- Non-canonical hooks usage and ViewModel transformer import in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:5)
|
||||
- ViewModel import in [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:10)
|
||||
- Action:
|
||||
- Convert each to PageQuery + PageClient.
|
||||
- Move hooks from top-level `apps/website/hooks/**` into `apps/website/lib/hooks/**` (canonical per [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:47)).
|
||||
- Rename the “transformer” pattern to a Presenter under `lib/view-models/**` and ensure it stays deterministic.
|
||||
|
||||
7. Profile routes (beyond `profile/leagues`)
|
||||
- Evidence: ViewModel import and locale formatting in [`profile/page.tsx`](apps/website/app/profile/page.tsx:16) and `toLocaleDateString` usage (e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430))
|
||||
- Action: convert to PageQuery + ProfilePageClient + ViewData.
|
||||
|
||||
8. Races listing route doing server-side filtering
|
||||
- Evidence: [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:39) uses `.filter` in route module
|
||||
- Action: same route split; if filtering is canonical (not UX-only), push it into API; if UX-only, do it client-side in a ViewModel.
|
||||
|
||||
### 4.3 “Templates” living under `app/**` (structural and guardrail gap)
|
||||
|
||||
The architecture assumes Templates are under `apps/website/templates/**`.
|
||||
|
||||
- Evidence: wallet UI is a “Template” in a route folder: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1) and it imports a ViewModel: [`LeagueWalletViewModel`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:7)
|
||||
|
||||
Actions:
|
||||
|
||||
1. Move this file to `apps/website/templates/LeagueWalletTemplate.tsx`.
|
||||
2. Ensure it takes ViewData only.
|
||||
3. Add a guardrail: forbid `*Template.tsx` under `apps/website/app/**`.
|
||||
|
||||
### 4.4 Remaining write-boundary violations (client-side POST)
|
||||
|
||||
Additional client-side logout write:
|
||||
|
||||
- Evidence: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/app/sponsor/settings/page.tsx:178)
|
||||
|
||||
Actions:
|
||||
|
||||
1. Replace with Server Action flow.
|
||||
2. Ensure any other write HTTP calls from client code are removed (search pattern already exists).
|
||||
|
||||
### 4.5 Locale formatting still present outside Templates (components + lib)
|
||||
|
||||
The strict contract forbids locale APIs in formatting paths; the codebase currently uses `toLocale*` heavily in:
|
||||
|
||||
- Components (examples):
|
||||
- [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75)
|
||||
- [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192)
|
||||
- [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
|
||||
- Infrastructure (example):
|
||||
- [`ErrorReplay.ts`](apps/website/lib/infrastructure/ErrorReplay.ts:214)
|
||||
- ViewModels (examples):
|
||||
- [`BillingViewModel`](apps/website/lib/view-models/BillingViewModel.ts:86)
|
||||
- [`SponsorshipViewModel`](apps/website/lib/view-models/SponsorshipViewModel.ts:48)
|
||||
- [`LeagueMemberViewModel`](apps/website/lib/view-models/LeagueMemberViewModel.ts:22)
|
||||
- [`PaymentViewModel`](apps/website/lib/view-models/PaymentViewModel.ts:44)
|
||||
- [`utilities/time.ts`](apps/website/lib/utilities/time.ts:41)
|
||||
|
||||
Actions (make it explicit):
|
||||
|
||||
1. Introduce deterministic formatting primitives under `apps/website/lib/display-objects/**` (or a dedicated deterministic formatter module) and route **all** formatting through them.
|
||||
2. Replace locale formatting calls in ViewModels first (largest fan-out).
|
||||
3. Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically.
|
||||
4. Add guardrails for `apps/website/components/**` too (not only Templates), otherwise the same issue migrates.
|
||||
|
||||
---
|
||||
|
||||
## 5) Third scan: contract checks against the questions
|
||||
|
||||
This section explicitly answers:
|
||||
|
||||
- do we have any filtering, sorting, formatting or any business logic in pages or ui components?
|
||||
- do we have clear separation of concerns?
|
||||
- did you consider correct DI usage?
|
||||
- did you consider server actions?
|
||||
|
||||
### 5.1 Filtering, sorting, formatting, business logic in `app/**`
|
||||
|
||||
Contract expectations:
|
||||
|
||||
- `app/**/page.tsx` should be server composition only ([`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
|
||||
- Formatting must be deterministic and must not use `Intl.*` or `toLocale*` ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41)).
|
||||
|
||||
Observed violations (representative, not exhaustive):
|
||||
|
||||
| Category | Evidence in `app/**` | Why this violates the contract | Required remediation |
|
||||
|---|---|---|---|
|
||||
| Filtering/sorting in `page.tsx` | `reduce/filter/sort` in [`computeDerivedData()`](apps/website/app/teams/page.tsx:14) | Server route does presentation shaping (guardrail violation). | Convert to PageQuery + `TeamsPageClient.tsx`; keep `page.tsx` orchestration only. |
|
||||
| Filtering inside route module | Filtering races in [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:40) | Filtering belongs in API (canonical) or in client ViewModel/PageClient (UX-only), not in route module. | Split route into PageQuery + PageClient. |
|
||||
| “Authorization-like” decisions in route modules | `isAdmin` derived from role in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:80) and [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:65) | Website may hide UI for UX but must not drift into enforcing security truth ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:186)). These checks are mixed with data/load/render responsibilities. | Ensure these are strictly UI flags derived from API truth and produced in client ViewModels/Presenters. |
|
||||
| Locale formatting in route modules | `toLocaleDateString/toLocaleString` in [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) and [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:210) | Forbidden locale APIs in formatting paths. | Replace with deterministic formatting, passed via ViewData. |
|
||||
| ViewModel instantiation in routes | `new DriverViewModel(...)` in [`leagues/[id]/standings/page.tsx`](apps/website/app/leagues/[id]/standings/page.tsx:66) | ViewModels are client-only ([`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)). | Replace with Page DTO; create ViewModels client-side only. |
|
||||
|
||||
### 5.2 Filtering, sorting, formatting, business logic in `components/**`
|
||||
|
||||
Observed violations (high signal):
|
||||
|
||||
| Category | Evidence in `components/**` | Why this violates the contract | Required remediation |
|
||||
|---|---|---|---|
|
||||
| Locale formatting in components | `toLocale*` in [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192), [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75), [`NotificationCenter.tsx`](apps/website/components/notifications/NotificationCenter.tsx:84) | Determinism/hydration risk; forbidden formatting path. | Ensure components consume deterministic ViewData strings/numbers produced by Presenters/Display Objects. |
|
||||
| Client-initiated write calls | `fetch('/api/auth/logout', { method: 'POST' })` in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | Violates Server Action-only write boundary ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)). | Replace with Server Action invoked via `<form action={...}>`. |
|
||||
|
||||
### 5.3 Separation of concerns
|
||||
|
||||
Answer: **No**; boundaries are blurred.
|
||||
|
||||
Concrete evidence:
|
||||
|
||||
1. Templates import ViewModels and compute.
|
||||
- Import example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7)
|
||||
- Compute example: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
|
||||
|
||||
2. Templates exist under `app/**`.
|
||||
- Example: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1)
|
||||
|
||||
3. Services construct ViewModels.
|
||||
- Example: `return new RaceDetailViewModel(...)` in [`RaceService`](apps/website/lib/services/races/RaceService.ts:24)
|
||||
- Example: `return ...map(... => new TeamSummaryViewModel(...))` in [`TeamService`](apps/website/lib/services/teams/TeamService.ts:35)
|
||||
|
||||
Remediation:
|
||||
|
||||
- Server: PageQueries return Page DTO.
|
||||
- Client: Presenters/ViewModels produce ViewData.
|
||||
- Templates: ViewData-only, no compute.
|
||||
|
||||
### 5.4 DI usage
|
||||
|
||||
Answer: **Not correct today**; server uses the singleton container.
|
||||
|
||||
Confirmed violations:
|
||||
|
||||
- Server route DI: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
|
||||
- Server PageQuery DI: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
|
||||
- Server helper enabling DI: [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:21)
|
||||
- Server service function DI: [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:9)
|
||||
- Dynamic import DI in PageQuery: [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
|
||||
|
||||
Remediation:
|
||||
|
||||
1. Add guardrail: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
|
||||
2. Refactor these call sites to manual wiring or request-scoped container.
|
||||
|
||||
### 5.5 Server Actions
|
||||
|
||||
Answer: **Not fully enforced**; client-side POSTs exist.
|
||||
|
||||
Confirmed violations:
|
||||
|
||||
- Client POST logout in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
|
||||
- Client POST logout in [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67)
|
||||
- Client POST logout in [`sponsor/settings/page.tsx`](apps/website/app/sponsor/settings/page.tsx:178)
|
||||
|
||||
Remediation:
|
||||
|
||||
1. Implement a Server Action for logout.
|
||||
2. Replace all client-side POSTs with `<form action={logoutAction}>`.
|
||||
3. Add guardrail to forbid `fetch` write methods in client code.
|
||||
|
||||
---
|
||||
|
||||
## A) DI contract violations (server-side singleton container usage)
|
||||
@@ -81,6 +617,273 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
|
||||
|
||||
---
|
||||
|
||||
## 6) Display Objects, Command Models, Blockers (fourth scan)
|
||||
|
||||
This section answers:
|
||||
|
||||
1) Are Display Objects used as intended?
|
||||
2) Where should we introduce Display Objects instead of ad-hoc formatting/mapping?
|
||||
3) Are Command Models used as intended?
|
||||
4) Where should we introduce Command Models instead of ad-hoc form state/validation?
|
||||
5) Are Blockers used as intended?
|
||||
6) Where should we introduce Blockers instead of ad-hoc UX prevention?
|
||||
|
||||
### 6.1 Display Objects: current state vs contract
|
||||
|
||||
Contract excerpts:
|
||||
|
||||
- Display Objects are deterministic UI-only formatting/mapping helpers ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1)).
|
||||
- Placement rule: `apps/website/lib/display-objects/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:141)).
|
||||
- Must be class-based, immutable, deterministic ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46)).
|
||||
- Must NOT call `Intl.*` or `toLocale*` ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63)).
|
||||
- Templates must not import Display Objects (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
|
||||
|
||||
#### 6.1.1 Violations: Display Objects imported from Templates
|
||||
|
||||
| Violation | Evidence | Why it matters | Required remediation |
|
||||
|---|---|---|---|
|
||||
| Template imports a Display Object | [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Templates are required to be ViewData-only and must not import display objects. | Move role badge derivation into Presenter/ViewModel and pass `{ text, badgeClasses }` as ViewData. |
|
||||
|
||||
#### 6.1.2 Structural drift: “Display Objects” implemented as plain functions/records
|
||||
|
||||
The following files live under `lib/display-objects/**`, but they are not class-based value objects (they are exported maps/functions):
|
||||
|
||||
- [`DashboardDisplay.ts`](apps/website/lib/display-objects/DashboardDisplay.ts:1)
|
||||
- [`ProfileDisplay.ts`](apps/website/lib/display-objects/ProfileDisplay.ts:1)
|
||||
- [`LeagueRoleDisplay.ts`](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1) (partly mitigated via wrapper class, but still map-backed)
|
||||
|
||||
This conflicts with the strict “class-based” rule in [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46). If we keep them as functions, we should either:
|
||||
|
||||
Clean-code direction (no exceptions):
|
||||
|
||||
- Refactor these modules into small, immutable classes with explicit APIs.
|
||||
- Keep them deterministic and free of locale APIs.
|
||||
|
||||
#### 6.1.3 Where Display Objects should be introduced (replace scattered formatting/mapping)
|
||||
|
||||
The repo currently performs formatting/mapping directly in Templates, route modules, components, and ViewModels. Those are prime candidates for Display Objects.
|
||||
|
||||
High-signal candidates found:
|
||||
|
||||
| Pattern | Evidence | Display Object to introduce | What the Display Object should output |
|
||||
|---|---|---|---|
|
||||
| Date formatting (`toLocaleDateString`, `toLocaleString`) | Many templates, e.g. [`RaceDetailTemplate.tsx`](apps/website/templates/RaceDetailTemplate.tsx:172), and route code e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) | `DeterministicDateDisplay` (class) | `{ shortDate, longDate, shortTime, dateTime }` as strings based on ISO inputs, deterministic timezone policy.
|
||||
| Number formatting (`toLocaleString`) | Templates like [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:206) and components like [`MetricCard.tsx`](apps/website/components/sponsors/MetricCard.tsx:49) | `DeterministicNumberDisplay` (class) | `{ compact, integer, fixed2 }` or explicit formatting helpers; avoid runtime locale.
|
||||
| Currency formatting | ViewModels like [`BillingViewModel.ts`](apps/website/lib/view-models/BillingViewModel.ts:85) | `MoneyDisplay` (class) | `formatted` string(s) given `{ currencyCode, minorUnits }` or `{ currency, amount }`.
|
||||
| Role badge mapping duplicated outside display objects | Switch in [`MembershipStatus.tsx`](apps/website/components/leagues/MembershipStatus.tsx:33) and also display object usage elsewhere ([`StandingsTable.tsx`](apps/website/components/leagues/StandingsTable.tsx:9)) | Consolidate as `LeagueRoleBadgeDisplay` | `{ text, badgeClasses }` (or `{ text, bg, border, textColor }`), deterministic.
|
||||
| Sponsorship status + time remaining UI mapping | Derived UI flags in [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:138) | `SponsorshipStatusDisplay` | `{ statusText, statusClasses, isExpiringSoon, daysRemainingLabel }` (pure).
|
||||
|
||||
**Important boundary rule:** Display Objects must be used by Presenters/ViewModels, not by Templates. Templates should only see the primitive outputs.
|
||||
|
||||
### 6.2 Command Models: current state vs intended use
|
||||
|
||||
Contract excerpts:
|
||||
|
||||
- Command Models are for transient form state; UX validation only; never derived from ViewModels; never reused from read models ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:61)).
|
||||
- Canonical placement: `apps/website/lib/command-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:219)).
|
||||
|
||||
#### 6.2.1 Good usage already present
|
||||
|
||||
- `League wizard` uses a command model: [`LeagueWizardCommandModel`](apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts:79) (consumed in [`CreateLeagueWizard.tsx`](apps/website/components/leagues/CreateLeagueWizard.tsx:27)).
|
||||
- `Auth` command models exist: [`LoginCommandModel`](apps/website/lib/command-models/auth/LoginCommandModel.ts:17) and [`SignupCommandModel`](apps/website/lib/command-models/auth/SignupCommandModel.ts:21).
|
||||
- `Protest` write intent uses a command model: [`ProtestDecisionCommandModel`](apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts:10) (constructed in [`protests/[protestId]/page.tsx`](apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx:171)).
|
||||
|
||||
#### 6.2.2 Violations / drift: Command-model-like logic living in components
|
||||
|
||||
Example: onboarding wizard duplicates field validation inline:
|
||||
|
||||
- Validation logic in [`OnboardingWizard.tsx`](apps/website/components/onboarding/OnboardingWizard.tsx:202)
|
||||
|
||||
Plan action:
|
||||
|
||||
1. Create `apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts`.
|
||||
2. Move field-level validation + errors to the command model.
|
||||
3. Keep the component responsible only for UI state (step navigation, show/hide) and invoking the server action.
|
||||
|
||||
Similarly, any other form pages that implement repeated validation strings/constraints should be moved into command models.
|
||||
|
||||
#### 6.2.3 Server Actions integration: command models should be consumed at the boundary
|
||||
|
||||
Contract requires writes to enter via Server Actions.
|
||||
|
||||
Plan action (repeatable pattern):
|
||||
|
||||
1. UI collects primitives → CommandModel instance (client-only) validates.
|
||||
2. Submit creates **Command DTO** (plain object) and calls a Server Action.
|
||||
3. Server Action performs UX validation (not business rules), calls API, redirects/revalidates.
|
||||
|
||||
### 6.3 Blockers: current state vs intended use
|
||||
|
||||
Contract excerpts:
|
||||
|
||||
- Blockers are UX-only, reversible helpers ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7)).
|
||||
- Client state is UI-only; blockers are not security ([`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:45)).
|
||||
- Canonical placement: `apps/website/lib/blockers/**` ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:48)).
|
||||
|
||||
#### 6.3.1 Violations: Blockers embedded in services (state leakage risk)
|
||||
|
||||
- [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores blockers as instance fields: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97).
|
||||
- [`LeagueWalletService`](apps/website/lib/services/leagues/LeagueWalletService.ts:12) does the same: [`submitBlocker`](apps/website/lib/services/leagues/LeagueWalletService.ts:13) and [`throttle`](apps/website/lib/services/leagues/LeagueWalletService.ts:14).
|
||||
|
||||
Why this matters:
|
||||
|
||||
- If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state.
|
||||
|
||||
Plan action:
|
||||
|
||||
1. Remove blockers from service instance state.
|
||||
2. Reintroduce blockers at the client boundary:
|
||||
- component local state
|
||||
- a `useSubmitBlocker()` hook under `apps/website/lib/hooks/**`
|
||||
- or a per-interaction blocker instance created inside a client function.
|
||||
|
||||
#### 6.3.2 Where blockers should be used instead of ad-hoc UX prevention
|
||||
|
||||
Candidates:
|
||||
|
||||
| UX need | Evidence | Recommended blocker |
|
||||
|---|---|---|
|
||||
| Prevent multiple logout clicks | Logout actions exist and currently do client POSTs: [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | `SubmitBlocker` at the UI boundary (button/form) |
|
||||
| Throttle rapid filter/search updates | Multiple pages render lists with client filtering/sorting, e.g. [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:72) | `ThrottleBlocker` inside a client container or hook (not in service) |
|
||||
|
||||
---
|
||||
|
||||
## 7) Hooks folder split (`apps/website/hooks` vs `apps/website/lib/hooks`)
|
||||
|
||||
Contract:
|
||||
|
||||
- React-only utilities MUST live under `apps/website/lib/hooks/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217), [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32)).
|
||||
|
||||
Observation:
|
||||
|
||||
- Repo contains **two** hooks locations:
|
||||
- Top-level: [`apps/website/hooks/useCapability.ts`](apps/website/hooks/useCapability.ts:1) (and many more)
|
||||
- Canonical: [`apps/website/lib/hooks/useEnhancedForm.ts`](apps/website/lib/hooks/useEnhancedForm.ts:1) (and others)
|
||||
|
||||
Violation:
|
||||
|
||||
| Violation | Evidence | Why it matters | Required remediation |
|
||||
|---|---|---|---|
|
||||
| Hooks implemented outside canonical `lib/hooks` | Many hooks under [`apps/website/hooks/**`](apps/website/hooks/useEffectiveDriverId.ts:1) despite contract naming `apps/website/lib/hooks/**` | Confuses imports/boundaries; increases chance hooks depend on route-level concerns or mix server/client incorrectly. Also violates the strict file-structure contract. | Move `apps/website/hooks/**` into `apps/website/lib/hooks/**`, update all imports, remove the top-level folder, and add a guardrail to prevent regression. |
|
||||
|
||||
Notes:
|
||||
|
||||
- The current `tsconfig` explicitly includes top-level `hooks/` ([`tsconfig.json`](apps/website/tsconfig.json:70)), which suggests this drift is intentional-but-undocumented.
|
||||
|
||||
---
|
||||
|
||||
## 8) `AdminViewModelService` violates ViewModel instantiation + Presenter placement rules
|
||||
|
||||
Contract:
|
||||
|
||||
- ViewModels are client-only classes; they are instantiated only in `'use client'` modules ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:76)).
|
||||
- Presenters (pure mappings) are colocated with ViewModels in `apps/website/lib/view-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:124)).
|
||||
- Services are for orchestration, not mapping API DTOs into ViewModels (implied by data-flow contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:145)).
|
||||
|
||||
Evidence:
|
||||
|
||||
- [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:5) is a `lib/services/**` module that performs mapping of API DTOs to ViewModels:
|
||||
- imports API DTO types from [`AdminApiClient`](apps/website/lib/services/AdminViewModelService.ts:1)
|
||||
- instantiates `AdminUserViewModel` et al. ([`AdminViewModelService.mapUser()`](apps/website/lib/services/AdminViewModelService.ts:14))
|
||||
|
||||
Violations:
|
||||
|
||||
| Violation | Evidence | Why it matters | Required remediation |
|
||||
|---|---|---|---|
|
||||
| ViewModels instantiated in a non-client module | [`new AdminUserViewModel(dto)`](apps/website/lib/services/AdminViewModelService.ts:15) in a service file with no `'use client'` boundary | If imported by server modules (Page Queries, RSC pages), it breaks the rule that ViewModels are client-only, and risks serializing or executing client-only assumptions on the server. | Move mapping into a Presenter colocated with the ViewModel (e.g. [`AdminUserViewModelPresenter`](apps/website/lib/view-models/AdminUserViewModel.ts:1) or a sibling file) and ensure ViewModel creation happens only in client modules. |
|
||||
| Presenter logic misclassified as a Service | All methods are pure mapping functions ([`AdminViewModelService.mapUsers()`](apps/website/lib/services/AdminViewModelService.ts:21)) | Encourages importing “services” in server paths, causing architecture erosion (services become dumping grounds). | Rename/rehome as Presenter. Services should call API clients + return Page DTOs, not construct ViewModels. |
|
||||
|
||||
---
|
||||
|
||||
## 9) Alignment plan (approved direction: align code to contract)
|
||||
## 9) Alignment plan (Clean Architecture / SOLID, no gaps)
|
||||
|
||||
Non-negotiable target:
|
||||
|
||||
- `app/**/page.tsx`: server composition only (no business logic, no formatting, no filtering).
|
||||
- `lib/page-queries/**`: server composition + IO only; returns Page DTO; **no ViewModels**; **no DI singleton**.
|
||||
- Server DI policy: **no DI at all** in PageQueries or `page.tsx` (manual wiring only).
|
||||
- `lib/services/**`: server-safe orchestration only; returns API DTO or Page DTO; **no ViewModels**; **no blockers**.
|
||||
- `lib/view-models/**` + Presenters: client-only; pure mapping to ViewData.
|
||||
- `templates/**`: dumb renderer; ViewData only.
|
||||
|
||||
### 9.1 Guardrails first (prevent regression)
|
||||
|
||||
Add CI-failing checks (tests or ESLint rules). These come first to enforce Clean Architecture boundaries while refactors are in flight:
|
||||
|
||||
1. `app/**/page.tsx` MUST NOT import from `apps/website/lib/view-models/**`.
|
||||
2. `templates/**` MUST NOT import from `apps/website/lib/view-models/**` nor `apps/website/lib/display-objects/**`.
|
||||
3. `lib/page-queries/**` MUST NOT import from `apps/website/lib/view-models/**`.
|
||||
4. `lib/services/**` MUST NOT import from `apps/website/lib/view-models/**`.
|
||||
5. Forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
|
||||
6. Forbid `Intl.*` and `toLocale*` in all presentation paths.
|
||||
7. Forbid `*Template.tsx` under `apps/website/app/**`.
|
||||
|
||||
Order of implementation (guardrails):
|
||||
|
||||
1. Server DI ban: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61) and forbid use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) from server modules.
|
||||
2. Server/client boundary: forbid any imports of `apps/website/lib/view-models/**` from `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**`.
|
||||
3. Template boundary: forbid imports of view-models and display-objects from `apps/website/templates/**`.
|
||||
4. Service boundary: forbid imports of view-models from `apps/website/lib/services/**`.
|
||||
5. Determinism: forbid `Intl.*` and `toLocale*` in all presentation paths.
|
||||
6. Write boundary: forbid client-side `fetch` with write methods in client modules.
|
||||
|
||||
These align cleanly with SRP (single reason to change per layer) and remove ambiguity.
|
||||
|
||||
### 9.2 Structural cleanups
|
||||
|
||||
1. Move all hooks to `apps/website/lib/hooks/**` (see §7 and §1.7).
|
||||
2. Convert Display Objects to class-based, immutable modules (see §6.1.2).
|
||||
3. Delete [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) and replace with a Presenter.
|
||||
|
||||
### 9.3 Service boundary refactor (DTO-only)
|
||||
|
||||
1. For each service currently returning ViewModels (examples: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95), [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:12), [`MediaService`](apps/website/lib/services/media/MediaService.ts:15)):
|
||||
- Change outputs to API DTOs or Page DTOs.
|
||||
- Move any ViewModel instantiation into client Presenters.
|
||||
- Remove blockers/state from services.
|
||||
|
||||
2. Fix the confirmed server/ViewModel leak:
|
||||
- [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) must not call `viewModel.toDTO()`.
|
||||
- It must call a service returning Page DTO and return that directly.
|
||||
|
||||
### 9.4 Route refactor recipe (repeatable)
|
||||
|
||||
For every route currently doing logic in `page.tsx`:
|
||||
|
||||
1. `page.tsx`: fetches via PageQuery, switches on result, renders a `*PageClient`.
|
||||
2. PageQuery: calls services + assembles Page DTO only.
|
||||
3. `*PageClient.tsx`: instantiates ViewModels (client-only) and produces ViewData via Presenters.
|
||||
4. Template: renders ViewData only.
|
||||
|
||||
Write path (per-route):
|
||||
|
||||
1. `app/<route>/actions.ts`: Server Actions for that route only (mutations, UX validation, redirect/revalidate).
|
||||
2. `*PageClient.tsx` (or a client component) uses `<form action={...}>` to invoke the action.
|
||||
3. Services called by actions remain DTO-only and do not contain UI logic.
|
||||
|
||||
This is the mechanism that keeps pages free from business logic and leaves no seams for drift.
|
||||
|
||||
### 9.5 ViewModels: allowed, but only behind the client boundary
|
||||
|
||||
You chose: keep ViewModels, enforce client-only.
|
||||
|
||||
Non-negotiable rules (to avoid gaps):
|
||||
|
||||
1. No server module imports from `apps/website/lib/view-models/**`.
|
||||
2. No `apps/website/lib/services/**` imports from `apps/website/lib/view-models/**`.
|
||||
3. A ViewModel instance is never passed into a Template.
|
||||
4. A ViewModel instance is never serialized.
|
||||
|
||||
Allowed flows:
|
||||
|
||||
- Page DTO → Presenter → ViewData → Template
|
||||
- Page DTO → Presenter → ViewModel → Presenter → ViewData → Template
|
||||
|
||||
This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels).
|
||||
|
||||
---
|
||||
|
||||
## High-signal file sets (pattern-based indicators)
|
||||
|
||||
### Templates importing ViewModels and or Display Objects (forbidden)
|
||||
@@ -100,4 +903,3 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
|
||||
|
||||
- API client exists at root `lib/` instead of `lib/api/`: [`apiClient.ts`](apps/website/lib/apiClient.ts:1)
|
||||
- Non-canonical `lib/page/` exists: [`PageDataFetcher.ts`](apps/website/lib/page/PageDataFetcher.ts:1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user