This commit is contained in:
2026-01-11 16:19:05 +01:00
parent 3069016bc6
commit dc7c747b93
31 changed files with 803 additions and 11987 deletions

View File

@@ -1,649 +0,0 @@
# Clean Architecture plan for GridPilot (NestJSfocused)
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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 (whats 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 modules 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 doesnt run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)).

View File

@@ -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 repos 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 Leagues 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?

View File

@@ -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 arent 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 cant 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 dont 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 (23 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)

View File

@@ -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”, its 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.

View File

@@ -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 its 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 doesnt 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

View File

@@ -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

View File

@@ -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.**

View File

@@ -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

View File

@@ -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.

View File

@@ -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! 🎨

View File

@@ -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

View File

@@ -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 presenters 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 controllers 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 isnt hydrated.
- Stewarding data fetch is N+1: for each race, fetch protests + penalties, which wont 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 its 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 its 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 APIs 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 dont 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.

View File

@@ -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 Objects 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/*`.

View File

@@ -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 projects 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 users 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 platforms 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 projects CQRS Light patterns in [`CQRS Light`](docs/architecture/CQRS.md:1).

View File

@@ -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

View File

@@ -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)."

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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 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) ## 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) ## High-signal file sets (pattern-based indicators)
### Templates importing ViewModels and or Display Objects (forbidden) ### 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) - 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) - Non-canonical `lib/page/` exists: [`PageDataFetcher.ts`](apps/website/lib/page/PageDataFetcher.ts:1)