diff --git a/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md b/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md deleted file mode 100644 index 68a2f733e..000000000 --- a/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md +++ /dev/null @@ -1,649 +0,0 @@ -# Clean Architecture plan for GridPilot (NestJS‑focused) - -This document defines the **target architecture** and **rules** for GridPilot. -It is written as a **developer-facing contract**: what goes where, what is allowed, and what is forbidden. - ---- - -## 1. Architectural goals - -* Strict Clean Architecture (dependency rule enforced) -* Domain-first design (DDD-inspired) -* Frameworks are delivery mechanisms, not architecture -* Business logic is isolated from persistence, UI, and infrastructure -* Explicit composition roots -* High testability without mocking the domain - ---- - -## 2. High-level structure - -``` -/apps - /api # NestJS API (delivery mechanism) - /website # Next.js website (delivery mechanism) - /electron # Electron app (delivery mechanism) - -/core # Business rules (framework-agnostic) - /analytics - /automation - /identity - /media - /notifications - /racing - /social - /shared - -/adapters # Technical implementations (details) - /persistence - /typeorm - /inmemory - /auth - /media - /notifications - /logging - -/testing # Test-only code (never shipped) - /contexts - /factories - /builders - /fakes - /fixtures -``` - ---- - -## 3. Dependency rule (non-negotiable) - -Dependencies **must only point inward**: - -``` -apps → adapters → core -``` - -Forbidden: - -* `core` importing from `adapters` -* `core` importing from `apps` -* domain entities importing ORM, NestJS, or framework code - ---- - -## 4. Core rules - -The Core contains **only business rules**. - -### Core MAY contain: - -* Domain entities -* Value objects -* Domain services -* Domain events -* Repository interfaces -* Application use cases -* Application-level ports - -### Core MUST NOT contain: - -* ORM entities -* Persistence implementations -* In-memory repositories -* NestJS decorators -* TypeORM decorators -* HTTP / GraphQL / IPC concerns -* Faker, demo data, seeds - ---- - -## 5. Domain entities - -Domain entities: - -* Represent business concepts -* Enforce invariants -* Contain behavior -* Are immutable or controlled via methods - -Example characteristics: - -* Private constructors -* Static factory methods -* Explicit validation -* Value objects for identity - -Domain entities **must never**: - -* Be decorated with `@Entity`, `@Column`, etc. -* Be reused as persistence models -* Know how they are stored - ---- - -## 6. Persistence entities (ORM) - -Persistence entities live in adapters and are **data-only**. - -``` -/adapters/persistence/typeorm/ - - 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/ - - PageViewMapper.ts -``` - -Rules: - -* Domain ↔ ORM mapping only happens in adapters -* Mappers are pure functions -* Boilerplate is acceptable and expected - ---- - -## 8. Repositories - -### Core - -``` -/core//domain/repositories - - IPageViewRepository.ts -``` - -Only interfaces. - -### Adapters - -``` -/adapters/persistence/typeorm/ - - PageViewTypeOrmRepository.ts - -/adapters/persistence/inmemory/ - - 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 {} -} -``` - -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. diff --git a/plans/2025-12-15_clean-architecture-migration-plan.md b/plans/2025-12-15_clean-architecture-migration-plan.md deleted file mode 100644 index 8d6cdfb6d..000000000 --- a/plans/2025-12-15_clean-architecture-migration-plan.md +++ /dev/null @@ -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 diff --git a/plans/2025-12-17_website-services.md b/plans/2025-12-17_website-services.md deleted file mode 100644 index 186ec0f09..000000000 --- a/plans/2025-12-17_website-services.md +++ /dev/null @@ -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 { - const dto = await this.apiClient.getDetail(raceId, driverId); - return this.raceDetailPresenter.present(dto); - } - - async getRacesPageData(): Promise { - const dto = await this.apiClient.getPageData(); - return this.racesPagePresenter.present(dto); - } - - async completeRace(raceId: string): Promise { - 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 { - const dto = await this.apiClient.getResultsDetail(raceId); - return this.resultsDetailPresenter.present(dto, currentUserId); - } - - async getWithSOF(raceId: string): Promise { - const dto = await this.apiClient.getWithSOF(raceId); - return this.sofPresenter.present(dto); - } - - async importResults(raceId: string, input: any): Promise { - 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 \ No newline at end of file diff --git a/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md b/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md deleted file mode 100644 index 22eb1b144..000000000 --- a/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md +++ /dev/null @@ -1,125 +0,0 @@ -# Plan: Switch API persistence from InMemory to Postgres (TypeORM), keep InMemory for tests - -Timestamp: 2025-12-28T20:17:49Z -Scope: **Racing bounded context first**, dev switchable between InMemory and Postgres, tests keep forcing InMemory. - -## Goals -- Make it **easy to switch** API persistence in dev via [`getApiPersistence()`](apps/api/src/env.ts:33) + [`process.env.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7). -- Default dev flow supports Postgres via Docker, but does **not** force it. -- Keep InMemory persistence intact and default for tests (already used in tests like [`process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'`](apps/api/src/domain/bootstrap/BootstrapSeed.http.test.ts:17)). -- Implement Postgres/TypeORM persistence for **racing repositories** currently provided by [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72). -- Provide **minimal idempotent seed** for Postgres so UI works (analogous to [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)). - -## Current state (what’s wired today) -- Env toggle exists: [`getApiPersistence()`](apps/api/src/env.ts:33). -- Postgres wiring exists but incomplete: [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) with `entities` commented and `synchronize` enabled outside production ([`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18)). -- Feature modules still hard-import in-memory racing persistence: - - [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:8) - - [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:8) - - [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:2) - - [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:2) - - [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:2) - - [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:2) - - [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:2) - - [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:11) -- Dev compose currently forces InMemory even when `.env` provides `DATABASE_URL`: - - Forced: [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36) - - `.env` hints inference from `DATABASE_URL`: [`DATABASE_URL=postgres://...`](.env.development.example:20) - -## High-level approach -1. Introduce a **persistence boundary module** for racing that selects implementation based on [`getApiPersistence()`](apps/api/src/env.ts:33). -2. Implement a Postgres/TypeORM module for racing repos (same tokens as in-memory). -3. Update racing-dependent API feature modules to import the boundary module (not the in-memory module). -4. Add minimal Postgres seed (dev-only, idempotent). -5. Fix dev compose to not hard-force InMemory. -6. Verify with lint/types/tests. - -## Milestones (execution order) -### M1 — Create racing persistence boundary (switch point) -- Add [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) - - `imports`: choose one of: - - [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) - - New [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1) - - `exports`: re-export the chosen module’s tokens, so downstream modules remain unchanged. - -Acceptance: -- No feature module directly imports [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) anymore. - -### M2 — Add Postgres racing persistence module (skeleton) -- Add [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1) - - Provides **the same tokens** defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51): - - [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52), [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53), etc. - - Uses `TypeOrmModule.forFeature([...entities])`. - - Must be compatible with DB root config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6). - -Acceptance: -- API can start with `GRIDPILOT_API_PERSISTENCE=postgres` and resolve racing repository providers (even if repo methods are initially stubbed during iteration). - -### M3 — Implement first working slice (League + Season + Membership + Race as needed) -- Implement ORM entities + mappers (ORM entities are not domain objects; follow clean architecture boundary from [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)). -- Implement TypeORM repositories for the minimal feature set used by: - - [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:7) - - [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:7) - -Strategy: -- Start from the endpoints exercised by existing HTTP tests that currently force InMemory (e.g. league schedule/roster tests), but run them in InMemory first; then add a small Postgres-specific smoke test later if needed. - -Acceptance: -- Core use cases depending on racing repos function against Postgres in dev. - -### M4 — Rewire feature modules to boundary module -Replace imports of [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) with [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) in: -- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:1) -- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:1) -- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:1) -- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:1) -- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:1) -- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:1) -- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:1) -- plus adjust [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) (see M5). - -Acceptance: -- Switching env var changes racing persistence without touching module imports. - -### M5 — Minimal idempotent Postgres seed (dev UX) -- Extend bootstrap so Postgres mode can seed minimal data when DB is empty. -- Current bootstrap behavior only seeds racing data for InMemory: [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37). -- Update logic to also seed for Postgres when: - - dev mode (non-prod), and - - tables empty (e.g., count leagues/drivers), and - - bootstrap enabled via [`getEnableBootstrap()`](apps/api/src/env.ts:49). - -Implementation note: -- Seed code should remain adapter-level (reuse [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)) but use repos from the active persistence module. - -Acceptance: -- `docker compose -f docker-compose.dev.yml up` + `GRIDPILOT_API_PERSISTENCE=postgres` results in a usable UI without manual DB setup. - -### M6 — Dev compose/env ergonomics -- Remove hard-coded forcing of InMemory in dev compose: - - Change/remove [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36) - - Prefer `.env.development` control, consistent with `.env example` guidance ([`DATABASE_URL`](.env.development.example:20)). - -Acceptance: -- Devs can switch by editing `.env.development` or setting env override. - -### M7 — Verification gate -Run: -- `eslint` -- `tsc` -- tests - -Commands live in workspace scripts; start from package-level scripts as applicable (e.g. API tests via [`npm run test`](apps/api/package.json:10)). - -Acceptance: -- No lint errors, no TypeScript errors, green tests (with default tests still using InMemory). - -## Out of scope (this pass) -- Social persistence (stays InMemory for now). -- Full migration system (placeholder remains, e.g. [`up()`](adapters/persistence/migrations/001_initial_schema.ts:5)). -- Production-ready DB lifecycle (migrations, RLS, etc.). - -## Risks / watchouts -- Provider scoping: racing repos are exported tokens; boundary module must avoid creating competing instances. -- Entity design: ORM entities must not leak into core (enforce boundary per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)). -- Bootstrap: ensure Postgres seed is idempotent and doesn’t run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)). \ No newline at end of file diff --git a/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md b/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md deleted file mode 100644 index e47e65210..000000000 --- a/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md +++ /dev/null @@ -1,419 +0,0 @@ -# Plan: Switch Racing persistence from InMemory to Postgres (TypeORM) while keeping InMemory for tests - -Timestamp: 2025-12-28T20:46:00Z -Scope: Racing bounded context persistence only (no Social/Identity/Media/Payments persistence changes) - -This plan is intentionally implementation-ready (what files to add, what to wire, what tests to write first) while keeping scope controlled: a minimal vertical slice that makes one meaningful League/Race workflow work in dev Postgres, but does not attempt to implement every Racing repository at once. - ---- - -## 0) Context (current state, must preserve) - -- Persistence toggle is already defined at [`getApiPersistence()`](apps/api/src/env.ts:33) and typed in [`ProcessEnv`](apps/api/src/env.d.ts:3). -- DB bootstrap exists as Nest module: [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), with non-prod schema sync enabled via [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18). -- Racing repository tokens are currently defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) and are used as Nest provider tokens. -- Persistence boundary already exists and selects between in-memory and Postgres: [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1). -- Postgres wiring for Racing is currently placeholder-only: [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51). -- Clean Architecture rules to enforce (no ORM leakage into Core): - - [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1) - - [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:1) - - File placement rules via [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:1) - ---- - -## 1) Goal and non-goals - -### Goal -Enable Racing persistence via Postgres/TypeORM for development runtime (selected via [`getApiPersistence()`](apps/api/src/env.ts:33)), while keeping default test runs using in-memory persistence (to keep CI fast and deterministic). - -### Non-goals (explicit scope control) -- No migration framework rollout, no production migration story beyond the existing non-prod `synchronize` behavior in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18). -- No broad refactors to use cases, DTOs, controllers, or domain modeling. -- No implementation of non-Racing bounded contexts (Social/Identity/Media/etc). -- No attempt to “finish all Racing repositories” in first pass; we will do a minimal vertical slice first. - ---- - -## 2) Proposed adapter folder/file layout (TypeORM under `adapters/`) - -This follows the existing adapter organization shown in [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:41) and the repo’s existing Racing adapter grouping under `adapters/racing/persistence/inmemory`. - -Create a parallel `typeorm` tree for Racing persistence: - -- `adapters/racing/persistence/typeorm/README.md` -- `adapters/racing/persistence/typeorm/entities/` - - `LeagueOrmEntity.ts` - - `SeasonOrmEntity.ts` - - `LeagueScoringConfigOrmEntity.ts` - - `RaceOrmEntity.ts` - - `LeagueMembershipOrmEntity.ts` -- `adapters/racing/persistence/typeorm/mappers/` - - `LeagueOrmMapper.ts` - - `SeasonOrmMapper.ts` - - `LeagueScoringConfigOrmMapper.ts` - - `RaceOrmMapper.ts` - - `LeagueMembershipOrmMapper.ts` -- `adapters/racing/persistence/typeorm/repositories/` - - `TypeOrmLeagueRepository.ts` - - `TypeOrmSeasonRepository.ts` - - `TypeOrmLeagueScoringConfigRepository.ts` - - `TypeOrmRaceRepository.ts` - - `TypeOrmLeagueMembershipRepository.ts` -- `adapters/racing/persistence/typeorm/testing/` (test-only helpers; never imported by prod code) - - `createTypeOrmTestDataSource.ts` - - `truncateRacingTables.ts` - -Notes: -- Do not add any `index.ts` barrel files due to the lint restriction in [`.eslintrc.json`](.eslintrc.json:36). -- All mapping logic must live in adapters (never in Core), per [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24). - ---- - -## 3) Minimal vertical slice (useful and controlled) - -### 3.1 “Meaningful workflow” target -Implement the smallest set that supports this end-to-end workflow in Postgres dev: - -1) Create a League (creates Season + scoring config) via the API call handled by the service method [`LeagueService.createLeague()`](apps/api/src/domain/league/LeagueService.ts:773) which uses [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56). -2) Fetch that League’s schedule (or races list) via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33), used by [`LeagueService.getLeagueSchedule()`](apps/api/src/domain/league/LeagueService.ts:614). - -This is a practical vertical slice: it enables the admin UI to create a league and see a schedule scaffold. - -### 3.2 Repos/tokens INCLUDED in slice 1 -Implement Postgres/TypeORM repositories for the following tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51): - -- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository` -- [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository` -- [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository` -- [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository` -- [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository` - -Rationale: -- Creation flow requires the first three via [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56). -- Schedule flow requires `league`, `season`, `race` repos via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33). -- Capacity listing and “join league” depend on membership repo via [`GetAllLeaguesWithCapacityUseCase`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:25) and [`JoinLeagueUseCase`](core/racing/application/use-cases/JoinLeagueUseCase.ts:18). - -### 3.3 Repos/tokens DEFERRED (slice 2+) -Explicitly defer these tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) to later phases: - -- [`DRIVER_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) -- [`RESULT_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:54) -- [`STANDING_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:55) -- [`RACE_REGISTRATION_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:57) -- [`TEAM_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:58) -- [`TEAM_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:59) -- Anything sponsorship/wallet-related in Racing persistence (tokens near [`LEAGUE_WALLET_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:66)) - -This keeps slice 1 focused and prevents exploding schema surface area. - ---- - -## 4) Mapping strategy (ORM entities vs domain entities) - -### 4.1 Boundary rule (non-negotiable) -Core domain entities (example [`League`](core/racing/domain/entities/League.ts:93), [`Race`](core/racing/domain/entities/Race.ts:18)) MUST NOT import or refer to ORM entities, repositories, decorators, or TypeORM types, per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16) and [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24). - -### 4.2 ORM entity design principles -- ORM entities are persistence models optimized for storage/querying and can use primitives (string, number, Date) and JSON columns. -- Domain entities use rich value objects and invariants and should be built using existing factories like [`League.create()`](core/racing/domain/entities/League.ts:132) and [`Race.create()`](core/racing/domain/entities/Race.ts:81). - -### 4.3 Mapping responsibilities -Mapping lives in `adapters/racing/persistence/typeorm/mappers/*` and is responsible for: - -- `toDomain(orm)`: - - Convert primitive columns/JSON back into domain props. - - Call domain factory methods (`create`) with validated values. - - Handle optional fields and backward-compatibility defaults (e.g., `League.settings` in [`League`](core/racing/domain/entities/League.ts:72)). -- `toOrm(domain)`: - - Convert domain value objects to primitives suitable for columns. - - Define canonical serialization for nested structures (e.g., store `League.settings` as JSONB). - -### 4.4 Proposed per-entity mapping notes (slice 1) - -#### League -- Persist fields: - - `id` string (PK) - - `name` string - - `description` string - - `ownerId` string - - `createdAt` Date - - `settings` JSONB (store `LeagueSettings` from [`LeagueSettings`](core/racing/domain/entities/League.ts:72)) - - `socialLinks` JSONB nullable - - `participantCount` integer (if needed; domain tracks via internal `_participantCount` in [`League`](core/racing/domain/entities/League.ts:103)) - - `visibility` string (redundant to settings.visibility, but may be useful for querying; keep either: - - Option A: derive from settings only and do not store separate column - - Option B: store both and enforce consistency in mapper (preferred for query ergonomics) - -#### Season -- Keep Season as its own ORM entity with FK to leagueId (string). -- Use JSONB for schedule (if schedule is a complex object), and scalar columns for status, year, order, start/end. - -#### LeagueScoringConfig -- Store `seasonId` string as unique FK. -- Store scoring config payload (championships, points tables, bonus rules) as JSONB. - -#### Race -- Persist scalar fields corresponding to [`Race`](core/racing/domain/entities/Race.ts:18): - - `id` string (PK) - - `leagueId` string (indexed) - - `scheduledAt` timestamptz - - `track`, `trackId`, `car`, `carId` strings - - `sessionType` string - - `status` string (from [`RaceStatus`](core/racing/domain/entities/Race.ts:11)) - - `strengthOfField`, `registeredCount`, `maxParticipants` integers nullable -- Queries required by Core ports (examples in [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10)): - - find by leagueId - - upcoming/completed filtering (status + scheduledAt) - -#### LeagueMembership -- Persist fields corresponding to [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts:25): - - `id` string (domain uses default `${leagueId}:${driverId}` in [`LeagueMembership.create()`](core/racing/domain/entities/LeagueMembership.ts:49)) - - `leagueId` string (indexed) - - `driverId` string (indexed) - - `role` string - - `status` string - - `joinedAt` timestamptz -- This enables membership queries required by [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13). - ---- - -## 5) TypeORM + Nest wiring specifics - -### 5.1 Database root config -Current `DatabaseModule` uses [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) and does not register entities. - -Plan change (minimal and controlled): -- Update [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) to support loading Racing entities when Postgres persistence is enabled: - - Add `autoLoadEntities: true` in the `forRoot` options so entities registered via feature modules are discovered. - - Keep [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18) behavior as-is for non-prod for now (explicitly acknowledged technical debt). - -Why: -- We want Racing persistence to be modular (entities registered only when the Postgres Racing module is imported) without a global “list every entity in the world” change. - -### 5.2 Postgres Racing module structure -Replace placeholder providers in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51) with real wiring: - -- `imports`: - - [`LoggingModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:3) (already present) - - Nest TypeORM feature registration for the slice 1 entities: - - `TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, LeagueScoringConfigOrmEntity, RaceOrmEntity, LeagueMembershipOrmEntity])` - - Mentioned as a method name; `TypeOrmModule` itself is already in use at [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6). - -- `providers`: - - Register each repository implementation class under the existing tokens, matching the in-memory token names exactly (examples in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:75)): - - Provide [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository` - - Provide [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository` - - Provide [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository` - - Provide [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository` - - Provide [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository` - -- `exports`: - - Export the same tokens so downstream modules remain unchanged, mirroring the export list pattern in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:182). - -### 5.3 Repository implementation style -Each `TypeOrm*Repository`: -- Implements the relevant Core repository interface, e.g. [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10). -- Depends only on: - - TypeORM repository/DataSource types (adapter-layer OK) - - Mappers in adapters - - Domain entities/ports (core-layer OK) -- Does not expose ORM entities outside adapters. - -### 5.4 Persistence boundary selection remains the same -Do not change selection semantics in [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:7). This module already selects Postgres vs in-memory using [`getApiPersistence()`](apps/api/src/env.ts:33). - ---- - -## 6) TDD-first phased rollout (tests first, controlled scope) - -### 6.1 Testing goals -- Add confidence that TypeORM repositories satisfy Core port contracts. -- Keep default test runs fast and in-memory by default. -- Add Postgres-backed integration tests that are opt-in (run only when explicitly enabled). - -### 6.2 What tests already exist and should remain green -- Persistence module selection test: [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) (currently asserts placeholder instance in Postgres mode). This will need updating once placeholders are replaced, but the intent remains valid. -- Existing in-memory repository unit tests under `adapters/racing/persistence/inmemory/*.test.ts` (example: [`InMemoryLeagueRepository.test.ts`](adapters/racing/persistence/inmemory/InMemoryLeagueRepository.test.ts)) must remain untouched and continue to run by default. - -### 6.3 New tests to write first (TDD sequence) - -#### Phase A: Contract-style repo tests for TypeORM (integration tests, opt-in) -Create a new test suite for each TypeORM repository in: -- `adapters/racing/persistence/typeorm/repositories/*Repository.integration.test.ts` - -Test approach: -- Use a real Postgres database (not mocks) and TypeORM DataSource configured similarly to runtime config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6). -- Keep these tests skipped unless a flag is set, e.g. `RUN_RACING_PG_TESTS=1` (exact naming to be decided in implementation mode). -- Use a dedicated DB name or schema per run and truncate tables between tests. - -Example test cases (for slice 1): -- `TypeOrmLeagueRepository` should satisfy basic operations defined in [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10): - - create + findById roundtrip - - findAll returns inserted - - update roundtrip - - exists works -- `TypeOrmSeasonRepository` should satisfy [`ISeasonRepository`](core/racing/domain/repositories/ISeasonRepository.ts:3): - - create + findById - - findByLeagueId -- `TypeOrmRaceRepository` should satisfy [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10): - - create + findById - - findByLeagueId - - findUpcomingByLeagueId and findCompletedByLeagueId behavior (status + date) -- `TypeOrmLeagueMembershipRepository` should satisfy [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13): - - saveMembership + getMembership - - getLeagueMembers filtering (active vs pending) must match whatever domain expects (start with minimal “returns all stored” behavior, then align with use cases) -- `TypeOrmLeagueScoringConfigRepository` should satisfy [`ILeagueScoringConfigRepository`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3): - - save + findBySeasonId - -Why integration tests first: -- It forces us to design the ORM schema + mapping in a way that matches the Core port contracts immediately. - -#### Phase B: Update module selection test (unit test, always-on) -Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert that in Postgres mode the provider resolves to the real TypeORM repo class (instead of the placeholder in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:30)). - -This remains a fast unit test: it only checks Nest DI wiring, not DB behavior. - -### 6.4 What to mock vs run “for real” -- Mock nothing for repository integration tests (they should hit Postgres). -- Keep Core use case tests (if any exist) running with in-memory repos or test doubles by default. -- Do not switch existing HTTP tests to Postgres by default (many explicitly set in-memory via env in files like those discovered under `apps/api/src/domain/league/*.http.test.ts` via earlier repo search). - -### 6.5 Keeping default tests in in-memory mode -- Preserve the current default behavior where tests set [`GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7) to `inmemory` (example in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)). -- Ensure the Postgres integration tests are opt-in and not included in default `npm run api:test` from [`apps/api/package.json`](apps/api/package.json:10). - ---- - -## 7) Dev bootstrap/seed strategy for Postgres (minimal, idempotent, non-test) - -### 7.1 Current behavior -Bootstrap currently seeds racing data only in in-memory mode via [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37), which returns `true` only for `inmemory`. - -### 7.2 Target behavior -In dev Postgres mode, seed minimal Racing data only when the database is empty, and never during tests. - -Proposed logic change in [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10): -- Seed when ALL are true: - - `NODE_ENV !== 'production'` - - persistence is Postgres via [`getApiPersistence()`](apps/api/src/env.ts:33) - - database appears empty for Racing (fast check: `driverRepository.findAll().length === 0` as already used in [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)) - - bootstrap is enabled (already toggled globally in app startup via [`getEnableBootstrap()`](apps/api/src/env.ts:49) and [`AppModule`](apps/api/src/app.module.ts:29)) - -Implementation detail: -- Keep using [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) because it is already idempotent-ish (it skips when drivers exist at [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)). -- Ensure Postgres-backed `driverRepository` is not required for slice 1 if we keep seed minimal; however, current seed checks drivers first, so this implies either: - - Option A (preferred for UI usefulness): include Driver repo in Postgres slice 1b (small extension) so seeding can run fully, or - - Option B (controlled scope): create a Postgres-only “minimal seed” class that checks `leagueRepository.findAll()` instead of drivers and seeds only leagues/seasons/races/memberships/scoring configs. - -To keep scope controlled and aligned with “Racing only”, choose Option B for slice 1: -- Introduce `SeedRacingDataMinimal` under `adapters/bootstrap/racing/` that seeds: - - one league - - one active season - - 0..N races in the season window - - one membership for the owner (so capacity endpoints have meaningful data) - - one scoring config for the active season -- Keep it idempotent: - - skip if `leagueRepository.findAll()` returns any leagues - - upsert behavior for scoring config by seasonId (align with [`ILeagueScoringConfigRepository.findBySeasonId()`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3)) - -Test contamination avoidance: -- Tests already default to in-memory persistence and can also set `GRIDPILOT_API_BOOTSTRAP` to false if needed via [`getEnableBootstrap()`](apps/api/src/env.ts:49). - ---- - -## 8) Dev ergonomics: docker-compose dev toggle change (exact change) - -### 8.1 Current issue -`docker-compose.dev.yml` forces in-memory persistence via [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36), which overrides the inference behavior in [`getApiPersistence()`](apps/api/src/env.ts:33). - -### 8.2 Proposed exact change (minimal) -Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) to stop hard-forcing in-memory: - -- Replace the hard-coded line at [`docker-compose.dev.yml`](docker-compose.dev.yml:36) with: - - `- GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-postgres}` - -Expected dev behavior: -- Default dev stack uses Postgres persistence (because compose default becomes `postgres`). -- Developers can still run in-memory explicitly by setting `GRIDPILOT_API_PERSISTENCE=inmemory` before running the compose command. - -Alternative (if you prefer to preserve auto-detection): -- Remove the line entirely and rely on [`getApiPersistence()`](apps/api/src/env.ts:33) + `DATABASE_URL` presence in `.env.development`. - -This plan recommends the explicit compose default approach because it is more deterministic and avoids hidden coupling to `.env.development` contents. - ---- - -## 9) Phased implementation plan (step-by-step) - -### Phase 1: Prepare TypeORM adapter skeleton (no behavior change) -1) Add the folder structure described in section 2. -2) Add the ORM entity files for the slice 1 domain models with minimal columns and constraints (PKs, required columns, basic indices). -3) Add mapper stubs with round-trip intent documented. -4) Add repository class stubs that implement the Core interfaces but throw “not implemented” only for methods not used in slice 1 tests. - -Gate: -- No changes to runtime wiring yet; existing tests remain green. - -### Phase 2: Add opt-in Postgres integration tests (TDD) -1) Add TypeORM DataSource test helper. -2) Write failing integration tests for `TypeOrmLeagueRepository` and implement it until green. -3) Repeat for `Season`, `Race`, `Membership`, `ScoringConfig` repos. - -Gate: -- Integration tests pass when enabled. -- Default `npm run api:test` remains unaffected. - -### Phase 3: Wire Postgres Racing module to real repos (DI correctness) -1) Update [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51): - - Import TypeORM feature registration (section 5.2). - - Replace placeholder providers with real repository providers for slice 1 tokens. -2) Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert the Postgres providers resolve to the real TypeORM repo classes (instead of placeholders). - -Gate: -- Always-on module selection tests pass. - -### Phase 4: Enable dev Postgres UX (bootstrap + compose) -1) Update [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) to seed minimal Racing data in dev Postgres mode only when empty. -2) Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) per section 8. - -Gate: -- `docker compose` dev stack can run with Postgres persistence and UI has minimal data. - -### Phase 5: Incrementally expand beyond slice 1 (future, explicitly not required to finish now) -Add Driver + Result + Standings etc only when a concrete UI/endpoint requires them and after writing the next integration tests first. - ---- - -## 10) Verification gates (exact commands and when) - -Run these at the end of each phase that changes TS code: - -- ESLint: - - `npm run lint` (script defined at [`package.json`](package.json:80)) -- TypeScript: - - `npm run typecheck:targets` (script defined at [`package.json`](package.json:120)) -- API tests: - - `npm run api:test` (script defined at [`package.json`](package.json:64)) or `npm run test --workspace=@gridpilot/api` via [`apps/api/package.json`](apps/api/package.json:10) - -For opt-in Postgres repository integration tests (added in Phase 2): -- Define a dedicated command (implementation-mode decision), but the plan expects it to be an explicit command that developers run intentionally (not part of default CI). - ---- - -## 11) Risks and mitigations - -- Risk: ORM entities leak into Core through shared types. - - Mitigation: enforce mappers in adapters only, keep interfaces as Core ports (example [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10)). -- Risk: Seed logic contaminates tests. - - Mitigation: preserve default in-memory persistence in tests (example env usage in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)) and gate seeding by non-prod + emptiness checks; tests can disable bootstrap via [`getEnableBootstrap()`](apps/api/src/env.ts:49). -- Risk: TypeORM entity registration not picked up because entities not configured. - - Mitigation: enable `autoLoadEntities` in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) as part of implementation. - ---- - -## 12) Ready-for-approval questions (for implementation mode to resolve quickly) - -These are the only decisions that materially affect implementation detail: - -1) Prefer Postgres integration tests using a developer-managed Postgres (via `DATABASE_URL`) or a dedicated docker-compose test database? -2) For slice 1 seed, should we implement a minimal Racing-only seed (recommended) or extend slice 1 to include Driver repo so we can reuse [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) unchanged? \ No newline at end of file diff --git a/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md b/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md deleted file mode 100644 index 27756b9db..000000000 --- a/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md +++ /dev/null @@ -1,300 +0,0 @@ -# Racing TypeORM Adapter Clean Architecture Audit + Strict Refactor Guide - -Scope focus: all persistence adapter code under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm:1), especially mappers (incl. JSON mappers) and repositories, plus the ORM entities and adapter-scoped errors they depend on. - -This guide is intentionally strict and implementation-ready. - ---- - -## Governing constraints (authoritative) - -- **Strict inward dependencies**: [`Only dependency-inward is allowed.`](docs/architecture/DATA_FLOW.md:24) -- **Domain purity / no IO in domain objects**: [`Entities MUST NOT perform IO.`](docs/architecture/DOMAIN_OBJECTS.md:49) -- **Persisted objects must rehydrate, not create**: [`Existing entities are reconstructed via rehydrate().`](docs/architecture/DOMAIN_OBJECTS.md:57) -- **Adapters translate only (no orchestration/business logic)**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) -- **Persistence entity placement**: ORM entities live in adapters, not domain: [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47) - ---- - -## Adapter surface inventory (audited) - -### Mappers -- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30) -- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5) -- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4) -- [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42) -- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17) -- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7) - -### Repositories -- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) -- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9) -- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9) -- [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9) - -### ORM entities -- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4) -- [`RaceOrmEntity`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:4) -- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4) -- [`LeagueScoringConfigOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts:6) - -### Adapter-scoped errors -- [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) - ---- - -## Concrete violations (file + function, why, severity) - -Severity rubric: -- **Blocker**: violates non-negotiable constraints; can cause domain invariants to run on persisted state, or leaks construction/orchestration into adapters. -- **Follow-up**: does not strictly violate constraints, but is unsafe/unclear and should be corrected to align with the canonical strict pattern defined below. - -### 1) Rehydration violations (calling `create()` when loading from DB) — **Blocker** - -- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) - - **Why**: Uses [`League.create()`](core/racing/domain/entities/League.ts:132) for persisted reconstruction. - - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57) semantics (new vs existing). - - **Impact**: running creation-time defaulting + validation on persisted state can mutate meaning (e.g., defaults merged in [`League.create()`](core/racing/domain/entities/League.ts:132)) and can throw domain validation errors due to persistence schema drift. - -- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) - - **Why**: Uses [`Race.create()`](core/racing/domain/entities/Race.ts:81) for persisted reconstruction. - - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57). - - **Impact**: DB rows become subject to “new entity” validations; adapter loses ability to separate “invalid persisted schema” (adapter concern) from “invalid new command” (domain concern). - -- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) - - **Why**: Uses [`Season.create()`](core/racing/domain/entities/season/Season.ts:70) for persisted reconstruction. - - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57). - - **Impact**: same as above, plus schedule/scoring/drop/stewarding props are passed as `any`, making persisted-state validation unpredictable. - -- Positive control (already compliant): [`LeagueScoringConfigOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:54) - - Uses [`LeagueScoringConfig.rehydrate()`](core/racing/domain/entities/LeagueScoringConfig.ts:63) and validates schema before converting. - - This is the baseline pattern to replicate. - -### 2) Adapter “translation only” violations (construction / orchestration inside repositories) — **Blocker** - -- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10) - - **Why**: Default-constructs a mapper via `new` ([`new LeagueOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:12)). - - **Rule violated**: adapters must “translate only” ([`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)); object graph construction belongs in the composition root (Nest module). - - **Impact**: makes DI inconsistent, harder to test, and encourages mapper graphs to be built ad-hoc in infrastructure code rather than composed centrally. - -- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10) - - **Why**: Default-constructs mapper via [`new RaceOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:12). - - **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) - - **Impact**: same. - -- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10) - - **Why**: Default-constructs mapper via [`new SeasonOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:12). - - **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) - -- Positive control (already aligned): [`TypeOrmLeagueScoringConfigRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:10) - - Requires mapper injection (no internal construction), and is enforced by [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17). - -### 3) Persistence schema typing issues that currently force `unknown`/`any` translation — **Follow-up (but required by the canonical strict pattern)** - -These aren’t explicitly spelled out in the architecture docs, but they directly undermine “adapters translate only” by making translation ambiguous and unsafe. They also block strict `rehydrate()` mapping because you can’t validate/interpret persisted JSON precisely. - -- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17) - - Current: `Record` - - 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 | 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`). - - Example target types: - - `LeagueOrmEntity.settings: SerializedLeagueSettings` - - `SeasonOrmEntity.schedule: SerializedSeasonSchedule | null` - - `SeasonOrmEntity.scoringConfig: SerializedSeasonScoringConfig | null` -- Serialized types must be: - - JSON-safe (no `Date`, `Map`, class instances) - - versionable (allow future `schemaVersion?: number`) - - strict enough to validate at runtime -- Use `null` for absent optional columns (as already done in [`RaceOrmEntity.trackId`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:17)). - -### B) Mapper rules (pure translation, no side effects) - -- Mappers are pure, deterministic translators: - - `domain -> orm` and `orm -> domain` - - no IO, no logging, no Date.now(), no random ids -- **No `as any`** in mapping. If types don’t line up, fix the schema types or add a safe interpreter. -- **No `create()` on load**. `orm -> domain` must call `rehydrate()` semantics for entities ([`rehydrate()` rule](docs/architecture/DOMAIN_OBJECTS.md:57)). -- On `orm -> domain`: - - validate persisted schema and throw an **adapter-scoped persistence schema error**, not a domain validation error. - - treat invalid persisted schema as infrastructure failure (data corruption/migration mismatch). -- On `domain -> orm`: - - serialize domain objects via explicit “serialize” helpers (no `as unknown as Record<...>`). -- JSON mappers: - - must be pure - - must return serialized DTO-like shapes, not domain objects except as output of `fromJson` - - should avoid calling value-object constructors directly unless the VO explicitly treats constructor as a safe “rehydrate”; otherwise introduce `fromJson()`/`rehydrate()` for VOs. - -### C) Repository rules (IO + mapper only; composition root in Nest module) - -- Repositories implement core ports and do: - - DB IO (TypeORM queries) - - mapping via injected mappers -- Repositories must not: - - construct mappers internally (`new` in constructor defaults) - - embed business logic/orchestration (that belongs to application services / use cases per [`Use Cases decide... Adapters translate.`](docs/architecture/DATA_FLOW.md:437)) -- Construction belongs in the Nest composition root: - - e.g. [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) should provide mapper instances and inject them into repositories. - -### D) Error handling rules (adapter-scoped errors vs domain errors) - -- Domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) are for rejecting invalid **commands/new state transitions**. -- Persistence adapters must throw adapter-scoped errors for: - - invalid persisted JSON schema - - impossible enum values/statuses stored in DB - - missing required persisted columns -- Pattern baseline: - - follow [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) (adapter-owned, descriptive name, extends `Error`). -- Repositories generally should not catch/translate DB errors unless you have a stable policy (e.g. unique violations) — keep this explicit and adapter-scoped if introduced. - ---- - -## Controlled refactor plan (2–3 slices) - -Each slice is designed to be reviewable and to keep the system runnable. - -### Slice 1 — Mappers: `rehydrate()` + typed JSON schemas (DB-free unit tests) - -**Goal** -- Make all `orm -> domain` mapping use rehydration semantics and strict, typed persisted schemas. -- Remove `as any` from mapper paths by fixing schema types and adding validators. - -**Files to touch (exact)** -- Mapper implementations: - - [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30) - - [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5) - - [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4) - - (keep as reference) [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42) - - JSON mappers as needed: - - [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7) - - [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17) -- ORM entities (type JSON columns to serialized types): - - [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4) - - [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4) -- Add adapter-scoped schema error types (new files under the existing folder): - - create new errors in [`adapters/racing/persistence/typeorm/errors/`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) -- Core changes required to satisfy rehydration rule (yes, this reaches into core because the adapter cannot comply otherwise): - - add `static rehydrate(...)` to: - - [`League`](core/racing/domain/entities/League.ts:93) - - [`Race`](core/racing/domain/entities/Race.ts:18) - - [`Season`](core/racing/domain/entities/season/Season.ts:14) - -**Acceptance tests (DB-free)** -- Add mapper unit tests in the same pattern as [`LeagueScoringConfigOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts:10): - - new: `LeagueOrmMapper.test.ts` verifies [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) calls `rehydrate()` and does not call [`League.create()`](core/racing/domain/entities/League.ts:132). - - new: `RaceOrmMapper.test.ts` verifies [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) uses `rehydrate()` and never calls [`Race.create()`](core/racing/domain/entities/Race.ts:81). - - new: `SeasonOrmMapper.test.ts` verifies [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) uses `rehydrate()` and never calls [`Season.create()`](core/racing/domain/entities/season/Season.ts:70). -- Add schema validation tests: - - invalid JSON column shapes throw adapter error types (similar to [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)), not domain validation errors. - -**Definition of done** -- No `create()` calls in any `toDomain()` for persisted entities. -- No `as any` in mapper implementations. -- JSON columns are typed to explicit `Serialized*` types, and validated on load. - ---- - -### Slice 2 — Repository wiring cleanup (DI, no `new` inside repos) - -**Goal** -- Repositories remain IO + mapper only. -- Mapper graphs are constructed in the Nest module composition root. - -**Files to touch (exact)** -- Repositories: - - [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) - - [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9) - - [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9) - - (already OK, keep consistent) [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9) -- Composition root wiring: - - [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) -- Integration tests that new repos directly (update constructor signatures): - - [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) -- Mapper tests that assume default constructors (update as needed): - - [`RacingOrmMappers.test.ts`](apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts:20) - -**Acceptance tests** -- Add repository constructor tests mirroring the existing pattern in [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17): - - new: `TypeOrmLeagueRepository.test.ts` asserts no internal `new LeagueOrmMapper()`. - - new: `TypeOrmRaceRepository.test.ts` asserts no internal `new RaceOrmMapper()`. - - new: `TypeOrmSeasonRepository.test.ts` asserts no internal `new SeasonOrmMapper()`. -- `tsc` should enforce injection by making mapper a required constructor param (strongest guard). - -**Definition of done** -- No repository has a default `new Mapper()` in constructor params. -- Nest module provides mapper instances and injects them into repositories. - ---- - -### Slice 3 (optional) — Postgres integration tests + minimal vertical verification - -**Goal** -- Verify the new strict schemas + rehydrate semantics survive real persistence roundtrips. - -**Files to touch (exact)** -- Existing integration test: - - [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:31) -- Potentially add one focused mapper+repo roundtrip test per aggregate if gaps remain: - - extend [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) rather than adding many files. - -**Acceptance tests** -- With `DATABASE_URL` set, integration suite passes and persists/reads: - - League with settings JSON - - Season with nullable JSON configs - - Race with status/sessionType mapping - - LeagueScoringConfig with championships JSON (already covered) - ---- - -## Notes on current wiring (for context) - -- Nest composition root is already the correct place for construction: - - [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) -- But it currently relies on repository default constructors for some mappers: - - e.g. provides [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) via `new TypeOrmLeagueRepository(dataSource)` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:66) -- League scoring config is already composed explicitly (good reference): - - constructs `PointsTableJsonMapper -> ChampionshipConfigJsonMapper -> LeagueScoringConfigOrmMapper` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:126) - ---- - -## Top blockers (short list) - -- Persisted entity mapping calls `create()` instead of `rehydrate()`: - - [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) - - [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) - - [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) -- Repositories build mappers internally (construction not confined to composition root): - - [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10) - - [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10) - - [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10) -- Untyped JSON columns and `as any` casts prevent strict translation and reliable schema error handling: - - [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17) - - [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32) - - [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) - - [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) \ No newline at end of file diff --git a/plans/2026-01-02_website-auth-route-protection-rethink.md b/plans/2026-01-02_website-auth-route-protection-rethink.md deleted file mode 100644 index f9a5fbdbe..000000000 --- a/plans/2026-01-02_website-auth-route-protection-rethink.md +++ /dev/null @@ -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=`. - -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` - -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` → `?returnTo=` -- `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` - -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 `//...`, this class strips the locale prefix. -- If you add Next `basePath`, this class can also strip it. - -This allows the rest of the auth system to remain stable even if the URL structure changes. - -### 3.1.8 `RouteCatalog` + `RoutePathBuilder` (no hardcoded strings) - -**Responsibility:** remove stringly-typed routes from the auth system. - -`RouteCatalog` exposes: - -- route IDs (e.g. `auth.login`, `protected.dashboard`, `sponsor.dashboard`, `admin.root`) -- route patterns (for matching): sourced from [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114) -- helpers built on existing matching tools like `routeMatchers` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193) - -`RoutePathBuilder` builds locale-aware URLs: - -- `build(routeId, params?, { locale? }): string` - -Implementation direction: - -- Use the existing `routes` object + `buildPath()` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:307) as the underlying canonical mapping. -- Add an optional locale prefix when i18n is introduced. - -With this, auth code never writes literals like `/auth/login`, `/dashboard`, `/sponsor/dashboard`. - -### 3.2 How the classes are used (App Router) - -Route enforcement happens in **server layouts**: - -- [`apps/website/app/dashboard/layout.tsx`](../apps/website/app/dashboard/layout.tsx:1) -- [`apps/website/app/admin/layout.tsx`](../apps/website/app/admin/layout.tsx:1) -- [`apps/website/app/sponsor/layout.tsx`](../apps/website/app/sponsor/layout.tsx:1) -- [`apps/website/app/profile/layout.tsx`](../apps/website/app/profile/layout.tsx:1) -- [`apps/website/app/onboarding/layout.tsx`](../apps/website/app/onboarding/layout.tsx:1) - -Each layout becomes a small server component wrapper: - -1. Instantiate `RouteGuard` with its collaborators. -2. `PathnameInterpreter` produces `{ locale, logicalPathname }`. -3. `await guard.enforce({ logicalPathname, locale })`. -3. Render children. - -### 3.3 How matching works without hardcoded paths - -When `RouteGuard` needs to answer questions like “is this an auth page?” or “does this require sponsor role?”, it should: - -- Match `logicalPathname` against patterns from `RouteCatalog`. -- Prefer the existing matcher logic in `routeMatchers` (see [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193)) so dynamic routes like `/leagues/[id]/settings` continue to work. - -This keeps auth rules stable even if later: - -- `/auth/login` becomes `/de/auth/login` -- or `/anmelden` in German via a localized route mapping - -because the matching happens against route IDs/patterns, not by string prefix checks. - -### 3.4 Middleware becomes minimal (or removed) - -After server layouts exist, middleware should either be: - -- **Removed entirely**, or -- Reduced to only performance/edge cases (static assets bypass, maybe public route list). - -Important: middleware cannot reliably call backend session endpoint in all environments without complexity/cost; server layouts can. - -### 3.5 Replace alpha mode with feature flags - -Alpha mode branch currently in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed. - -Target: - -- Introduce a feature flags source (existing system in repo) and a small provider. -- Feature flags decide: - - which navigation items are shown - - which pages/features are enabled - - which UI shell is used (if we need an “alpha shell”, it’s just a flag) - -Rules: - -- Feature flags must not bypass auth/authorization. -- Feature flags must be evaluated server-side for initial render, and optionally rehydrated client-side. - -### 3.6 Demo user without logic exceptions - -Replace “demo mode cookies” with: - -- A standard login flow that returns a normal `gp_session` cookie. -- Demo login endpoint remains acceptable in non-production, but it should: - - authenticate as a *predefined seeded user* - - return a normal session payload - - set only `gp_session` - - not set or depend on `gridpilot_demo_mode`, sponsor id/name cookies - -Update all UI that reads `gridpilot_demo_mode` to read session role instead. - ---- - -## 4) Migration plan (implementation sequence, class-driven) - -This is ordered to keep tests green most of the time and reduce churn. - -### Step 0 — Document and freeze behavior - -- Confirm redirect semantics match integration tests: - - unauthenticated protected → `/auth/login?returnTo=...` - - wrong-role protected → same redirect - - authenticated hitting `/auth/login` → redirect to role home (tests currently assert `/dashboard` or `/sponsor/dashboard`) - -### Step 1 — Introduce the classes (incl. i18n-ready routing) - -- Implement `RouteCatalog` + `RoutePathBuilder` first (removes hardcoded strings, enables i18n later). -- Implement `PathnameInterpreter` (normalize pathnames). -- Implement `RouteAccessPolicy` + `ReturnToSanitizer` next (pure logic, easy unit tests). -- Implement `SessionGateway` (server-only). -- Implement `AuthRedirectBuilder` (pure + uses sanitizer/policy). -- Implement `RouteGuard` (composition). - -### Step 2 — Convert protected layouts to server enforcement using `RouteGuard` - -### Step 3 — Fix auth routes and redirects (server-first) - -### Step 4 — Remove alpha mode branches and replace with `FeatureFlagService` - -### Step 5 — Remove demo cookies and demo logic exceptions - -### Step 6 — Simplify or delete middleware - -- Remove all `gridpilot_demo_mode`, sponsor id/name cookies usage. -- Ensure sponsor role is derived from session. - -### Step 7 — Update integration tests - -- If server layouts cover all protected routes, middleware can be deleted. -- If kept, it should only do cheap routing (no role logic, no demo logic). - -### Step 8 — Delete obsolete code + tighten tests - -- Update cookie setup in [`tests/integration/website/websiteAuth.ts`](../tests/integration/website/websiteAuth.ts:1): - - stop setting demo cookies - - keep drift cookies if still supported by API - - rely solely on `gp_session` from demo-login - -- Update expectations in [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) only if necessary. - -### Step 9 — Run repo verifications - -- `eslint` -- `tsc` -- integration tests including [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) - ---- - -## 5) Files to remove (expected deletions) - -These are the primary candidates to delete because they become redundant or incorrect under the new concept. - -### 5.1 Website auth/route-protection code to delete - -- [`apps/website/lib/guards/AuthGuard.tsx`](../apps/website/lib/guards/AuthGuard.tsx:1) -- [`apps/website/lib/guards/RoleGuard.tsx`](../apps/website/lib/guards/RoleGuard.tsx:1) -- [`apps/website/lib/guards/AuthGuard.test.tsx`](../apps/website/lib/guards/AuthGuard.test.tsx:1) -- [`apps/website/lib/guards/RoleGuard.test.tsx`](../apps/website/lib/guards/RoleGuard.test.tsx:1) - -Rationale: client-side guards are replaced by server-side enforcement in layouts. - -### 5.2 Website Next route handlers that conflict with the canonical API auth flow - -- [`apps/website/app/auth/iracing/start/route.ts`](../apps/website/app/auth/iracing/start/route.ts:1) -- [`apps/website/app/auth/iracing/callback/route.ts`](../apps/website/app/auth/iracing/callback/route.ts:1) - -Rationale: these are placeholder/mocks and should be replaced with a single canonical auth flow via the API. - -### 5.3 Website logout route handler (currently incorrect) - -- [`apps/website/app/auth/logout/route.ts`](../apps/website/app/auth/logout/route.ts:1) - -Rationale: deletes `gp_demo_session` instead of `gp_session` and duplicates API logout. - -### 5.4 Demo-cookie driven UI (to remove or refactor) - -These files likely contain `gridpilot_demo_mode` logic and must be refactored to session-based logic; if purely demo-only, delete. - -- [`apps/website/components/dev/DevToolbar.tsx`](../apps/website/components/dev/DevToolbar.tsx:1) (refactor: use session, not demo cookies) -- [`apps/website/components/profile/UserPill.tsx`](../apps/website/components/profile/UserPill.tsx:1) (refactor) -- [`apps/website/components/sponsors/SponsorInsightsCard.tsx`](../apps/website/components/sponsors/SponsorInsightsCard.tsx:1) (refactor) - -Note: these are not guaranteed deletions, but demo-cookie logic in them must be removed. - -### 5.5 Alpha mode (to remove) - -- “Alpha mode” branching in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed. - -Whether any specific “alpha-only” files are deleted depends on feature flag mapping; the hard requirement is: no `mode === 'alpha'` routing/auth exceptions remain. - ---- - -## 6) Acceptance criteria - -- There is exactly one canonical place where access is enforced: server layouts. -- Middleware contains no auth/role/demo logic (or is deleted). -- Auth logic has zero hardcoded pathname strings; it relies on route IDs + builders and is i18n-ready. -- No code uses `gridpilot_demo_mode` or sponsor-id/name cookies to drive auth/redirect logic. -- Demo login returns a normal session; “demo user” behaves like any other user. -- Alpha mode is removed; feature flags are used instead. -- Integration tests under [`tests/integration/website`](../tests/integration/website/auth-flow.test.ts:1) pass. -- Repo checks pass: eslint + tsc + tests. diff --git a/plans/2026-01-03_demo-users-seed-only-plan.md b/plans/2026-01-03_demo-users-seed-only-plan.md deleted file mode 100644 index 1df2d747c..000000000 --- a/plans/2026-01-03_demo-users-seed-only-plan.md +++ /dev/null @@ -1,289 +0,0 @@ -# Plan: Remove demo-login logic; use seed-only predefined demo users - -## Goal - -Replace current demo-login feature (custom endpoint + special-case behavior) with **predefined demo users created by seeding only**. - -Constraints from request: - -* No extra demo-login code in “core” or “website” (beyond normal email+password login). -* Demo users exist because the seed created them. -* Remove role/ID hacks and mock branches that exist only for demo-login. - -## Current demo-login touchpoints to remove / refactor - -### API (Nest) - -* Demo login use case and wiring: - * [`apps/api/src/development/use-cases/DemoLoginUseCase.ts`](apps/api/src/development/use-cases/DemoLoginUseCase.ts) - * Demo login endpoint: - * [`apps/api/src/domain/auth/AuthController.ts`](apps/api/src/domain/auth/AuthController.ts) - * Demo login method in service: - * [`apps/api/src/domain/auth/AuthService.ts`](apps/api/src/domain/auth/AuthService.ts) - * Demo login providers / presenter injection: - * [`apps/api/src/domain/auth/AuthProviders.ts`](apps/api/src/domain/auth/AuthProviders.ts) - * [`apps/api/src/domain/auth/presenters/DemoLoginPresenter.ts`](apps/api/src/domain/auth/presenters/DemoLoginPresenter.ts) - * Demo login DTO type: - * [`apps/api/src/domain/auth/dtos/AuthDto.ts`](apps/api/src/domain/auth/dtos/AuthDto.ts) -* Production guard special-case: - * [`apps/api/src/domain/auth/ProductionGuard.ts`](apps/api/src/domain/auth/ProductionGuard.ts) -* Dashboard “demo user” mock branch: - * [`apps/api/src/domain/dashboard/DashboardService.ts`](apps/api/src/domain/dashboard/DashboardService.ts) - -### Website - -* Demo login UI and calls: - * Login page demo button calls demo-login: - * [`apps/website/app/auth/login/page.tsx`](apps/website/app/auth/login/page.tsx) - * Sponsor signup “demo” flow calls demo-login: - * [`apps/website/app/sponsor/signup/page.tsx`](apps/website/app/sponsor/signup/page.tsx) - * DevToolbar demo login section calls demo-login and infers role from email patterns: - * [`apps/website/components/dev/DevToolbar.tsx`](apps/website/components/dev/DevToolbar.tsx) -* Client API/types: - * [`apps/website/lib/api/auth/AuthApiClient.ts`](apps/website/lib/api/auth/AuthApiClient.ts) - * Generated demo DTO type: - * [`apps/website/lib/types/generated/DemoLoginDTO.ts`](apps/website/lib/types/generated/DemoLoginDTO.ts) - -### Tests - -* Smoke/integration helpers fetch demo-login to obtain cookies: - * [`tests/smoke/websiteAuth.ts`](tests/smoke/websiteAuth.ts) - * [`tests/integration/website/websiteAuth.ts`](tests/integration/website/websiteAuth.ts) -* Integration tests asserting demo-login endpoint: - * [`tests/integration/website/auth-flow.test.ts`](tests/integration/website/auth-flow.test.ts) -* Test docker compose enables demo-login: - * [`docker-compose.test.yml`](docker-compose.test.yml) - -### Core - -* There is a demo identity provider type in core: - * [`core/identity/application/ports/IdentityProviderPort.ts`](core/identity/application/ports/IdentityProviderPort.ts) - * Keep or remove depends on whether it’s a real abstraction used outside demo-login. - -## Proposed clean solution (seed-only) - -### 1) Define the canonical demo accounts (single source of truth) - -We will define a fixed set of demo users with: - -* fixed email addresses (already used in demo-login) -* one fixed password (user-approved): `Demo1234!` -* stable user IDs (so other seeded objects can reference them) — **important to remove the need for prefix heuristics** - -Recommended roles/emails (existing patterns): - -* driver: `demo.driver@example.com` -* sponsor: `demo.sponsor@example.com` -* league-owner: `demo.owner@example.com` -* league-steward: `demo.steward@example.com` -* league-admin: `demo.admin@example.com` -* system-owner: `demo.systemowner@example.com` -* super-admin: `demo.superadmin@example.com` - -IDs: - -* Prefer deterministic IDs via existing seed ID helpers, e.g. [`adapters/bootstrap/racing/SeedIdHelper.ts`](adapters/bootstrap/racing/SeedIdHelper.ts) -* Decide whether the **session id** should be `userId` vs `primaryDriverId` and enforce that consistently. - -### 2) Seed creation: add demo users to the bootstrap seed path - -We already have a robust bootstrapping / seed mechanism: - -* API bootstraps racing data via [`apps/api/src/domain/bootstrap/BootstrapModule.ts`](apps/api/src/domain/bootstrap/BootstrapModule.ts) and [`adapters/bootstrap/SeedRacingData.ts`](adapters/bootstrap/SeedRacingData.ts) - -Plan: - -* Add an **identity seed step** that creates the demo users (and any required linked domain objects like sponsor account/admin user rows). -* Make it **idempotent**: create if missing, update if mismatched (or delete+recreate under force reseed). -* Ensure it runs in: - * `NODE_ENV=test` (so tests can login normally) - * `inmemory` persistence (dev default) - * postgres non-production when bootstrap is enabled (consistent with current bootstrap approach) - -### 3) Remove demo-login endpoint and all supporting glue - -Delete/cleanup: - -* API: `POST /auth/demo-login` and use case/presenter/provider wiring. -* Env flags: remove `ALLOW_DEMO_LOGIN` usage. -* Website: remove demo login calls and any demo-login generated DTO. -* Tests: stop calling demo-login. - -Result: demo login becomes “type email + password (Demo1234!)” like any other login. - -### 4) Remove “demo user” hacks (mock branches, role heuristics) - -Key removals: - -* DashboardService demo mock branch in [`apps/api/src/domain/dashboard/DashboardService.ts`](apps/api/src/domain/dashboard/DashboardService.ts) - * Replace with real data from seeded racing entities. - * If a demo role needs different dashboard shape, that should come from real seeded data + permissions, not hardcoded `driverId.startsWith(...)`. - -* Website DevToolbar role inference based on email substrings in [`apps/website/components/dev/DevToolbar.tsx`](apps/website/components/dev/DevToolbar.tsx) - * With seed-only demo users, the toolbar doesn’t need to guess; it can just show the current session. - -### 5) Update tests to use normal login - -Replace demo-login cookie setup with: - -* Ensure demo users exist (seed ran in test environment) -* Call the normal login endpoint (API or Next.js rewrite) to get a real `gp_session` cookie - -Targets: - -* [`tests/smoke/websiteAuth.ts`](tests/smoke/websiteAuth.ts) -* [`tests/integration/website/websiteAuth.ts`](tests/integration/website/websiteAuth.ts) -* Any tests that assert demo-login behavior should be rewritten to assert seeded login behavior. - -Docker test stack: - -* Remove `ALLOW_DEMO_LOGIN=true` from [`docker-compose.test.yml`](docker-compose.test.yml) -* Ensure bootstrap+seed runs for identity in test. - -## Architecture alignment (docs/architecture) - -This plan aligns with the principles in: - -* “API is source of truth; client is UX only”: - * [`docs/architecture/QUICK_AUTH_REFERENCE.md`](docs/architecture/QUICK_AUTH_REFERENCE.md) -* Avoid hardcoded special cases and unpredictable flows: - * [`docs/architecture/CLEAN_AUTH_SOLUTION.md`](docs/architecture/CLEAN_AUTH_SOLUTION.md) - * [`docs/architecture/UNIFIED_AUTH_CONCEPT.md`](docs/architecture/UNIFIED_AUTH_CONCEPT.md) - -Demo users are data, not behavior. - -## Implementation Status - -### ✅ Completed Subtasks - -1. **Add demo-user seed module (idempotent) to bootstrap** - COMPLETED - - Created `SeedDemoUsers` class in `adapters/bootstrap/SeedDemoUsers.ts` - - Defined 7 demo users with fixed emails and password `Demo1234!` - - Implemented idempotent creation/update logic - - Added deterministic ID generation - -2. **Wire seed into API startup path** - COMPLETED - - Integrated `SeedDemoUsers` into `BootstrapModule` - - Added conditional seeding logic (dev/test only, respects bootstrap flags) - - Added force reseed support via `GRIDPILOT_API_FORCE_RESEED` - -3. **Delete demo-login endpoint and supporting code** - COMPLETED - - Removed `POST /auth/demo-login` endpoint - - Removed `DemoLoginUseCase` and related presenters/providers - - Removed `ALLOW_DEMO_LOGIN` environment variable usage - - Cleaned up production guard special-cases - -4. **Remove dashboard demo mock branch** - COMPLETED - - Removed demo user mock branch from `DashboardService` - - Dashboard now uses real seeded data - -5. **Remove website demo-login UI and API client methods** - COMPLETED - - Removed demo login button from login page - - Removed demo flow from sponsor signup - - Cleaned up DevToolbar demo login section - - Removed demo-login API client methods and types - -6. **Update tests to use normal login** - COMPLETED - - Updated smoke tests to use seeded credentials - - Updated integration tests to use normal login - - Removed demo-login endpoint assertions - - Updated test docker compose to remove `ALLOW_DEMO_LOGIN` - -7. **Update docs to describe demo accounts + seeding** - COMPLETED - - Created `docs/DEMO_ACCOUNTS.md` as single source of truth - - Updated any existing docs with demo-login references - - Documented environment variables and usage - -8. **Verify: eslint, tsc, unit tests, integration tests** - COMPLETED - - All code changes follow project standards - - TypeScript types are correct - - Tests updated to match new behavior - -### Summary of Accomplishments - -**What was removed:** -- Custom demo-login endpoint (`/api/auth/demo-login`) -- `ALLOW_DEMO_LOGIN` environment variable -- Demo-login use case, presenters, and providers -- Demo user mock branches in DashboardService -- Demo login UI buttons and flows in website -- Demo-login specific test helpers and assertions - -**What was added:** -- `SeedDemoUsers` class for creating demo users during bootstrap -- 7 predefined demo users with fixed emails and `Demo1234!` password -- `GRIDPILOT_API_FORCE_RESEED` environment variable for reseeding -- `docs/DEMO_ACCOUNTS.md` documentation -- Idempotent demo user creation logic - -**How it works now:** -1. Demo users are created automatically during API startup (dev/test only) -2. Users log in with normal email/password flow -3. No special demo-login code exists anywhere -4. Demo users have stable IDs and proper roles -5. All authentication flows use the same code path - -### Remaining Work - -None identified. The demo-login feature has been completely replaced with seed-only demo users. - -### Architecture Alignment - -This implementation follows the principles: -- **Single source of truth**: Demo accounts defined in one place (`SeedDemoUsers`) -- **No special cases**: Demo users are regular users created by seeding -- **Clean separation**: Authentication logic unchanged, only data initialization added -- **Environment-aware**: Demo users only in dev/test, never production -- **Idempotent**: Safe to run multiple times, respects force reseed flag - -### Files Created - -- `docs/DEMO_ACCOUNTS.md` - Complete documentation for demo accounts - -### Files Modified - -- `adapters/bootstrap/SeedDemoUsers.ts` - Demo user seed implementation -- `apps/api/src/domain/bootstrap/BootstrapModule.ts` - Integrated demo user seeding -- `apps/api/src/domain/bootstrap/BootstrapProviders.ts` - Added demo user seed provider -- `tests/smoke/websiteAuth.ts` - Updated to use seeded login -- `tests/integration/website/websiteAuth.ts` - Updated to use seeded login -- `tests/integration/website/auth-flow.test.ts` - Updated to test seeded login -- `docker-compose.test.yml` - Removed ALLOW_DEMO_LOGIN -- `plans/2026-01-03_demo-users-seed-only-plan.md` - This document updated - -### Environment Variables - -**New:** -- `GRIDPILOT_API_FORCE_RESEED` - Force reseed demo users - -**Removed:** -- `ALLOW_DEMO_LOGIN` - No longer needed - -**Existing (unchanged):** -- `GRIDPILOT_API_BOOTSTRAP` - Controls all seeding -- `GRIDPILOT_API_PERSISTENCE` - Database type -- `NODE_ENV` - Environment mode - -### Demo Users Available - -All use password: `Demo1234!` - -1. `demo.driver@example.com` - John Driver (user) -2. `demo.sponsor@example.com` - Jane Sponsor (user) -3. `demo.owner@example.com` - Alice Owner (owner) -4. `demo.steward@example.com` - Bob Steward (user with admin) -5. `demo.admin@example.com` - Charlie Admin (admin) -6. `demo.systemowner@example.com` - Diana SystemOwner (admin) -7. `demo.superadmin@example.com` - Edward SuperAdmin (admin) - -## Success Criteria Met - -✅ Demo accounts documentation exists -✅ No references to demo-login endpoint in docs -✅ No references to ALLOW_DEMO_LOGIN in docs -✅ Plan document updated with completion status -✅ All subtasks completed successfully -✅ Architecture principles maintained -✅ Tests updated and passing -✅ Code follows project standards - diff --git a/plans/API_DRIVEN_FEATURE_CONFIG.md b/plans/API_DRIVEN_FEATURE_CONFIG.md deleted file mode 100644 index 779400556..000000000 --- a/plans/API_DRIVEN_FEATURE_CONFIG.md +++ /dev/null @@ -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; - - 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 { - 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(SESSION_SERVICE_TOKEN); - const landingService = container.get(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: - -// 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 ( - - - - {children} - - - - ); -} -``` - -### 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 \ No newline at end of file diff --git a/plans/CLEAN_ARCHITECTURE_FIX_PLAN.md b/plans/CLEAN_ARCHITECTURE_FIX_PLAN.md deleted file mode 100644 index 45f346941..000000000 --- a/plans/CLEAN_ARCHITECTURE_FIX_PLAN.md +++ /dev/null @@ -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 // ❌ Wrong - ) {} - - async execute(input: GetRaceDetailInput): Promise> { - 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> { - 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 { - 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` from constructor - - Change return type from `Promise>` to `Promise>` - - 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` from constructor -- [ ] Change return type from `Promise>` to `Promise>` -- [ ] 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` 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.** \ No newline at end of file diff --git a/plans/DI_PLAN.md b/plans/DI_PLAN.md deleted file mode 100644 index e9d906800..000000000 --- a/plans/DI_PLAN.md +++ /dev/null @@ -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(null); - -export function ContainerProvider({ children }: { children: ReactNode }) { - const container = useMemo(() => createContainer(), []); - - return ( - - {children} - - ); -} - -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(token: symbol): T { - const container = useContext(ContainerContext); - if (!container) throw new Error('Missing ContainerProvider'); - - return useMemo(() => container.get(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(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 ; -} -``` - -### 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 }) => ( - - - {children} - - - ) - }); - - // 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 \ No newline at end of file diff --git a/plans/DTO-Refactor-Super-Detailed-Plan.md b/plans/DTO-Refactor-Super-Detailed-Plan.md deleted file mode 100644 index 02188c167..000000000 --- a/plans/DTO-Refactor-Super-Detailed-Plan.md +++ /dev/null @@ -1,2137 +0,0 @@ -# Super Detailed DTO Refactor Plan for apps/website - -## Executive Summary - -This plan addresses the massive DTO/ViewModel pollution in `apps/website/lib/apiClient.ts` (1160 lines, 80+ types) and inline definitions in pages like `apps/website/app/races/[id]/results/page.tsx`. It enforces [DATA_FLOW.md](apps/website/DATA_FLOW.md) strictly: API DTOs → Presenters → View Models → Services → UI. Results in ~100 new files, apiClient shrunk 95%, zero inline DTOs in UI. - -## Current State Analysis - -### Problem 1: Monolithic apiClient.ts -**Location**: `apps/website/lib/apiClient.ts` (1160 lines) - -**Violations**: -- 80+ type definitions mixed (transport DTOs + UI ViewModels + inputs/outputs) -- Single file responsibility violation -- No separation between HTTP layer and data transformation -- Direct UI coupling to transport shapes - -### Problem 2: Inline Page DTOs -**Location**: `apps/website/app/races/[id]/results/page.tsx` (lines 17-56) - -**Violations**: -- Pages defining transport contracts -- No reusability -- Tight coupling to implementation details -- Violates presenter pattern - -### Problem 3: Missing Architecture Layers -**Current**: UI → apiClient (mixed DTOs/ViewModels) -**Required**: UI → Services → Presenters → API (DTOs only) - -## Phase 1: Complete Type Inventory & Classification - -### Step 1.1: Catalog All apiClient.ts Types (Lines 13-634) - -Create spreadsheet/document with columns: -1. Type Name (current) -2. Line Number Range -3. Classification (DTO/ViewModel/Input/Output) -4. Target Location -5. Dependencies -6. Used By (pages/components) - -**Complete List of 80+ Types**: - -#### Common/Shared Types (Lines 13-54) -```typescript -// Line 13-19: DriverDTO -export interface DriverDTO { - id: string; - name: string; - avatarUrl?: string; - iracingId?: string; - rating?: number; -} -// Classification: Transport DTO -// Target: apps/website/lib/dtos/DriverDto.ts -// Dependencies: None -// Used By: Multiple (races, teams, leagues) - -// Line 21-29: ProtestViewModel -export interface ProtestViewModel { - id: string; - raceId: string; - complainantId: string; - defendantId: string; - description: string; - status: string; - createdAt: string; -} -// Classification: UI ViewModel -// Target: apps/website/lib/view-models/ProtestViewModel.ts -// Dependencies: None -// Used By: races/[id]/protests, leagues/[id]/admin - -// Line 31-36: LeagueMemberViewModel -export interface LeagueMemberViewModel { - driverId: string; - driver?: DriverDTO; - role: string; - joinedAt: string; -} -// Classification: UI ViewModel -// Target: apps/website/lib/view-models/LeagueMemberViewModel.ts -// Dependencies: DriverDTO -// Used By: leagues/[id]/members - -// ... (continue for ALL 80 types with same detail level) -``` - -#### League Domain Types (Lines 57-152) -- LeagueSummaryViewModel (57-70) → DTO + ViewModel -- AllLeaguesWithCapacityViewModel (72-74) → DTO -- LeagueStatsDto (76-79) → DTO -- LeagueJoinRequestViewModel (80-87) → ViewModel -- LeagueAdminPermissionsViewModel (88-95) → ViewModel -- LeagueOwnerSummaryViewModel (97-102) → ViewModel -- LeagueConfigFormModelDto (104-111) → DTO -- LeagueAdminProtestsViewModel (113-115) → ViewModel -- LeagueSeasonSummaryViewModel (117-123) → ViewModel -- LeagueMembershipsViewModel (125-127) → ViewModel -- LeagueStandingsViewModel (129-131) → ViewModel -- LeagueScheduleViewModel (133-135) → ViewModel -- LeagueStatsViewModel (137-145) → ViewModel -- LeagueAdminViewModel (147-151) → ViewModel -- CreateLeagueInput (153-159) → Input DTO -- CreateLeagueOutput (161-164) → Output DTO - -#### Driver Domain Types (Lines 166-199) -- DriverLeaderboardItemViewModel (167-175) → ViewModel -- DriversLeaderboardViewModel (177-179) → ViewModel -- DriverStatsDto (181-183) → DTO -- CompleteOnboardingInput (185-188) → Input DTO -- CompleteOnboardingOutput (190-193) → Output DTO -- DriverRegistrationStatusViewModel (195-199) → ViewModel - -#### Team Domain Types (Lines 201-273) -- TeamSummaryViewModel (202-208) → ViewModel -- AllTeamsViewModel (210-212) → ViewModel -- TeamMemberViewModel (214-219) → ViewModel -- TeamJoinRequestItemViewModel (221-227) → ViewModel -- TeamDetailsViewModel (229-237) → ViewModel -- TeamMembersViewModel (239-241) → ViewModel -- TeamJoinRequestsViewModel (243-245) → ViewModel -- DriverTeamViewModel (247-252) → ViewModel -- CreateTeamInput (254-258) → Input DTO -- CreateTeamOutput (260-263) → Output DTO -- UpdateTeamInput (265-269) → Input DTO -- UpdateTeamOutput (271-273) → Output DTO - -#### Race Domain Types (Lines 275-447) -- RaceListItemViewModel (276-284) → ViewModel -- AllRacesPageViewModel (286-288) → ViewModel -- RaceStatsDto (290-292) → DTO -- RaceDetailEntryViewModel (295-302) → ViewModel -- RaceDetailUserResultViewModel (304-313) → ViewModel -- RaceDetailRaceViewModel (315-326) → ViewModel -- RaceDetailLeagueViewModel (328-336) → ViewModel -- RaceDetailRegistrationViewModel (338-341) → ViewModel -- RaceDetailViewModel (343-350) → ViewModel -- RacesPageDataRaceViewModel (352-364) → ViewModel -- RacesPageDataViewModel (366-368) → ViewModel -- RaceResultViewModel (370-381) → ViewModel -- RaceResultsDetailViewModel (383-387) → ViewModel -- RaceWithSOFViewModel (389-393) → ViewModel -- RaceProtestViewModel (395-405) → ViewModel -- RaceProtestsViewModel (407-410) → ViewModel -- RacePenaltyViewModel (412-420) → ViewModel -- RacePenaltiesViewModel (423-426) → ViewModel -- RegisterForRaceParams (428-431) → Input DTO -- WithdrawFromRaceParams (433-435) → Input DTO -- ImportRaceResultsInput (437-439) → Input DTO -- ImportRaceResultsSummaryViewModel (441-447) → ViewModel - -#### Sponsor Domain Types (Lines 449-502) -- GetEntitySponsorshipPricingResultDto (450-454) → DTO -- SponsorViewModel (456-461) → ViewModel -- GetSponsorsOutput (463-465) → Output DTO -- CreateSponsorInput (467-472) → Input DTO -- CreateSponsorOutput (474-477) → Output DTO -- SponsorDashboardDTO (479-485) → DTO -- SponsorshipDetailViewModel (487-496) → ViewModel -- SponsorSponsorshipsDTO (498-502) → DTO - -#### Media Domain Types (Lines 504-514) -- RequestAvatarGenerationInput (505-508) → Input DTO -- RequestAvatarGenerationOutput (510-514) → Output DTO - -#### Analytics Domain Types (Lines 516-536) -- RecordPageViewInput (517-521) → Input DTO -- RecordPageViewOutput (523-525) → Output DTO -- RecordEngagementInput (527-532) → Input DTO -- RecordEngagementOutput (534-536) → Output DTO - -#### Auth Domain Types (Lines 538-556) -- LoginParams (539-542) → Input DTO -- SignupParams (544-548) → Input DTO -- SessionData (550-556) → DTO - -#### Payments Domain Types (Lines 558-633) -- PaymentViewModel (559-565) → ViewModel -- GetPaymentsOutput (567-569) → Output DTO -- CreatePaymentInput (571-577) → Input DTO -- CreatePaymentOutput (579-582) → Output DTO -- MembershipFeeViewModel (584-589) → ViewModel -- MemberPaymentViewModel (591-596) → ViewModel -- GetMembershipFeesOutput (598-601) → Output DTO -- PrizeViewModel (603-609) → ViewModel -- GetPrizesOutput (611-613) → Output DTO -- WalletTransactionViewModel (615-621) → ViewModel -- WalletViewModel (623-628) → ViewModel -- GetWalletOutput (630-633) → Output DTO - -### Step 1.2: Catalog Page Inline DTOs - -**File**: `apps/website/app/races/[id]/results/page.tsx` - -```typescript -// Lines 17-24: PenaltyTypeDTO -type PenaltyTypeDTO = - | 'time_penalty' - | 'grid_penalty' - | 'points_deduction' - | 'disqualification' - | 'warning' - | 'license_points' - | string; -// Target: apps/website/lib/dtos/PenaltyTypeDto.ts - -// Lines 26-30: PenaltyData -interface PenaltyData { - driverId: string; - type: PenaltyTypeDTO; - value?: number; -} -// Target: apps/website/lib/dtos/PenaltyDataDto.ts - -// Lines 32-42: RaceResultRowDTO -interface RaceResultRowDTO { - id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; - getPositionChange(): number; -} -// Target: apps/website/lib/dtos/RaceResultRowDto.ts -// Note: Remove method, make pure data - -// Lines 44-46: DriverRowDTO -interface DriverRowDTO { - id: string; - name: string; -} -// Target: Reuse DriverDto from common - -// Lines 48-56: ImportResultRowDTO -interface ImportResultRowDTO { - id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; -} -// Target: apps/website/lib/dtos/ImportResultRowDto.ts -``` - -**Action Items**: -1. Scan all files in `apps/website/app/` for inline type/interface definitions -2. Create extraction plan for each -3. Document dependencies and usage - -## Phase 2: Directory Structure Creation - -### Step 2.1: Create Base Directories - -```bash -# Execute these commands in order: -mkdir -p apps/website/lib/dtos -mkdir -p apps/website/lib/view-models -mkdir -p apps/website/lib/presenters -mkdir -p apps/website/lib/services -mkdir -p apps/website/lib/api/base -mkdir -p apps/website/lib/api/leagues -mkdir -p apps/website/lib/api/drivers -mkdir -p apps/website/lib/api/teams -mkdir -p apps/website/lib/api/races -mkdir -p apps/website/lib/api/sponsors -mkdir -p apps/website/lib/api/media -mkdir -p apps/website/lib/api/analytics -mkdir -p apps/website/lib/api/auth -mkdir -p apps/website/lib/api/payments -``` - -### Step 2.2: Create Placeholder Index Files - -```typescript -// apps/website/lib/dtos/index.ts -// This file will be populated in Phase 3 -export {}; - -// apps/website/lib/view-models/index.ts -// This file will be populated in Phase 4 -export {}; - -// apps/website/lib/presenters/index.ts -// This file will be populated in Phase 6 -export {}; - -// apps/website/lib/services/index.ts -// This file will be populated in Phase 7 -export {}; - -// apps/website/lib/api/index.ts -// This file will be populated in Phase 5 -export {}; -``` - -## Phase 3: Extract DTOs (60+ Files) - -### Step 3.1: Common DTOs - -#### apps/website/lib/dtos/DriverDto.ts -```typescript -/** - * Driver transport object - * Represents a driver as received from the API - */ -export interface DriverDto { - id: string; - name: string; - avatarUrl?: string; - iracingId?: string; - rating?: number; -} -``` - -#### apps/website/lib/dtos/PenaltyTypeDto.ts -```typescript -/** - * Penalty type enumeration - * Defines all possible penalty types in the system - */ -export type PenaltyTypeDto = - | 'time_penalty' - | 'grid_penalty' - | 'points_deduction' - | 'disqualification' - | 'warning' - | 'license_points'; -``` - -#### apps/website/lib/dtos/PenaltyDataDto.ts -```typescript -import type { PenaltyTypeDto } from './PenaltyTypeDto'; - -/** - * Penalty data structure - * Used when creating or updating penalties - */ -export interface PenaltyDataDto { - driverId: string; - type: PenaltyTypeDto; - value?: number; -} -``` - -### Step 3.2: League DTOs - -#### apps/website/lib/dtos/LeagueStatsDto.ts -```typescript -/** - * League statistics transport object - */ -export interface LeagueStatsDto { - totalLeagues: number; -} -``` - -#### apps/website/lib/dtos/LeagueSummaryDto.ts -```typescript -/** - * League summary transport object - * Contains basic league information for list views - */ -export interface LeagueSummaryDto { - id: string; - name: string; - description?: string; - logoUrl?: string; - coverImage?: string; - memberCount: number; - maxMembers: number; - isPublic: boolean; - ownerId: string; - ownerName?: string; - scoringType?: string; - status?: string; -} -``` - -#### apps/website/lib/dtos/CreateLeagueInputDto.ts -```typescript -/** - * Create league input - * Data required to create a new league - */ -export interface CreateLeagueInputDto { - name: string; - description?: string; - isPublic: boolean; - maxMembers: number; - ownerId: string; -} -``` - -#### apps/website/lib/dtos/CreateLeagueOutputDto.ts -```typescript -/** - * Create league output - * Response from league creation - */ -export interface CreateLeagueOutputDto { - leagueId: string; - success: boolean; -} -``` - -### Step 3.3: Race DTOs - -#### apps/website/lib/dtos/RaceStatsDto.ts -```typescript -/** - * Race statistics transport object - */ -export interface RaceStatsDto { - totalRaces: number; -} -``` - -#### apps/website/lib/dtos/RaceResultRowDto.ts -```typescript -/** - * Individual race result transport object - * Pure data, no methods - */ -export interface RaceResultRowDto { - id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; -} -``` - -#### apps/website/lib/dtos/RaceResultsDetailDto.ts -```typescript -import type { RaceResultRowDto } from './RaceResultRowDto'; - -/** - * Complete race results transport object - */ -export interface RaceResultsDetailDto { - raceId: string; - track: string; - results: RaceResultRowDto[]; -} -``` - -#### apps/website/lib/dtos/RegisterForRaceInputDto.ts -```typescript -/** - * Register for race input - */ -export interface RegisterForRaceInputDto { - leagueId: string; - driverId: string; -} -``` - -### Step 3.4: Driver DTOs - -#### apps/website/lib/dtos/DriverStatsDto.ts -```typescript -/** - * Driver statistics transport object - */ -export interface DriverStatsDto { - totalDrivers: number; -} -``` - -#### apps/website/lib/dtos/CompleteOnboardingInputDto.ts -```typescript -/** - * Complete onboarding input - */ -export interface CompleteOnboardingInputDto { - iracingId: string; - displayName: string; -} -``` - -#### apps/website/lib/dtos/CompleteOnboardingOutputDto.ts -```typescript -/** - * Complete onboarding output - */ -export interface CompleteOnboardingOutputDto { - driverId: string; - success: boolean; -} -``` - -### Step 3.5: Barrel Export (apps/website/lib/dtos/index.ts) - -```typescript -// Common -export * from './DriverDto'; -export * from './PenaltyTypeDto'; -export * from './PenaltyDataDto'; - -// League -export * from './LeagueStatsDto'; -export * from './LeagueSummaryDto'; -export * from './CreateLeagueInputDto'; -export * from './CreateLeagueOutputDto'; -// ... add all league DTOs - -// Race -export * from './RaceStatsDto'; -export * from './RaceResultRowDto'; -export * from './RaceResultsDetailDto'; -export * from './RegisterForRaceInputDto'; -// ... add all race DTOs - -// Driver -export * from './DriverStatsDto'; -export * from './CompleteOnboardingInputDto'; -export * from './CompleteOnboardingOutputDto'; -// ... add all driver DTOs - -// Team, Sponsor, Media, Analytics, Auth, Payments... -// Continue for all domains -``` - -**Total DTO Files**: ~60-70 files - -## Phase 4: Create View Models (30+ Files) - -### Step 4.1: Understanding ViewModel Pattern - -**What ViewModels Add**: -1. UI-specific derived fields -2. Computed properties -3. Display formatting -4. UI state indicators -5. Grouped/sorted data for rendering - -**What ViewModels DO NOT Have**: -1. Business logic -2. Validation rules -3. API calls -4. Side effects - -### Step 4.2: League ViewModels - -#### apps/website/lib/view-models/LeagueSummaryViewModel.ts -```typescript -import type { LeagueSummaryDto } from '../dtos'; - -/** - * League summary view model - * Extends DTO with UI-specific computed properties - */ -export interface LeagueSummaryViewModel extends LeagueSummaryDto { - // Formatted capacity display (e.g., "25/50") - formattedCapacity: string; - - // Percentage for progress bars (0-100) - capacityBarPercent: number; - - // Button label based on state - joinButtonLabel: string; - - // Quick check flags - isFull: boolean; - isJoinable: boolean; - - // Color indicator for UI - memberProgressColor: 'green' | 'yellow' | 'red'; - - // Badge type for status display - statusBadgeVariant: 'success' | 'warning' | 'info'; -} -``` - -#### apps/website/lib/view-models/LeagueStandingsViewModel.ts -```typescript -import type { DriverDto } from '../dtos'; - -/** - * Single standings entry view model - */ -export interface StandingEntryViewModel { - // From DTO - driverId: string; - driver?: DriverDto; - position: number; - points: number; - wins: number; - podiums: number; - races: number; - - // UI additions - positionBadge: 'gold' | 'silver' | 'bronze' | 'default'; - pointsGapToLeader: number; - pointsGapToNext: number; - isCurrentUser: boolean; - trend: 'up' | 'down' | 'stable'; - trendArrow: '↑' | '↓' | '→'; -} - -/** - * Complete standings view model - */ -export interface LeagueStandingsViewModel { - standings: StandingEntryViewModel[]; - totalEntries: number; - currentUserPosition?: number; -} -``` - -### Step 4.3: Race ViewModels - -#### apps/website/lib/view-models/RaceResultViewModel.ts -```typescript -/** - * Individual race result view model - * Extends result data with UI-specific fields - */ -export interface RaceResultViewModel { - // From DTO - driverId: string; - driverName: string; - avatarUrl: string; - position: number; - startPosition: number; - incidents: number; - fastestLap: number; - - // Computed UI fields - positionChange: number; - positionChangeDisplay: string; // "+3", "-2", "0" - positionChangeColor: 'green' | 'red' | 'gray'; - - // Status flags - isPodium: boolean; - isWinner: boolean; - isClean: boolean; - hasFastestLap: boolean; - - // Display helpers - positionBadge: 'gold' | 'silver' | 'bronze' | 'default'; - incidentsBadgeColor: 'green' | 'yellow' | 'red'; - lapTimeFormatted: string; // "1:23.456" -} -``` - -#### apps/website/lib/view-models/RaceResultsDetailViewModel.ts -```typescript -import type { RaceResultViewModel } from './RaceResultViewModel'; - -/** - * Complete race results view model - * Includes statistics and sorted views - */ -export interface RaceResultsDetailViewModel { - raceId: string; - track: string; - results: RaceResultViewModel[]; - - // Statistics for display - stats: { - totalFinishers: number; - podiumFinishers: number; - cleanRaces: number; - averageIncidents: number; - fastestLapTime: number; - fastestLapDriver: string; - }; - - // Sorted views for different displays - resultsByPosition: RaceResultViewModel[]; - resultsByFastestLap: RaceResultViewModel[]; - cleanDrivers: RaceResultViewModel[]; - - // User-specific data - currentUserResult?: RaceResultViewModel; - currentUserHighlighted: boolean; -} -``` - -### Step 4.4: Driver ViewModels - -#### apps/website/lib/view-models/DriverLeaderboardViewModel.ts -```typescript -/** - * Single leaderboard entry view model - */ -export interface DriverLeaderboardItemViewModel { - // From DTO - id: string; - name: string; - avatarUrl?: string; - rating: number; - wins: number; - races: number; - - // UI additions - skillLevel: string; - skillLevelColor: string; - skillLevelIcon: string; - winRate: number; - winRateFormatted: string; // "45.2%" - ratingTrend: 'up' | 'down' | 'stable'; - ratingChangeIndicator: string; // "+50", "-20" - position: number; - positionBadge: 'gold' | 'silver' | 'bronze' | 'default'; -} - -/** - * Complete leaderboard view model - */ -export interface DriversLeaderboardViewModel { - drivers: DriverLeaderboardItemViewModel[]; - totalDrivers: number; - currentPage: number; - pageSize: number; - hasMore: boolean; -} -``` - -### Step 4.5: Barrel Export (apps/website/lib/view-models/index.ts) - -```typescript -// League -export * from './LeagueSummaryViewModel'; -export * from './LeagueStandingsViewModel'; -export * from './LeagueMemberViewModel'; -// ... all league ViewModels - -// Race -export * from './RaceResultViewModel'; -export * from './RaceResultsDetailViewModel'; -export * from './RaceListItemViewModel'; -// ... all race ViewModels - -// Driver -export * from './DriverLeaderboardViewModel'; -export * from './DriverRegistrationStatusViewModel'; -// ... all driver ViewModels - -// Team, etc. -``` - -**Total ViewModel Files**: ~30-40 files - -## Phase 5: API Client Refactor (10+ Files) - -### Step 5.1: Base API Client - -#### apps/website/lib/api/base/BaseApiClient.ts -```typescript -/** - * Base HTTP client for all API communication - * Provides common request/response handling - */ -export class BaseApiClient { - private baseUrl: string; - - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } - - /** - * Generic request handler - * @param method HTTP method - * @param path API path - * @param data Request body (optional) - * @returns Response data - */ - protected async request( - method: string, - path: string, - data?: object - ): Promise { - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - - const config: RequestInit = { - method, - headers, - credentials: 'include', // Include cookies for auth - }; - - if (data) { - config.body = JSON.stringify(data); - } - - const response = await fetch(`${this.baseUrl}${path}`, config); - - if (!response.ok) { - let errorData: { message?: string } = { message: response.statusText }; - try { - errorData = await response.json(); - } catch { - // Keep default error message - } - throw new Error( - errorData.message || `API request failed with status ${response.status}` - ); - } - - const text = await response.text(); - if (!text) { - return null as T; - } - return JSON.parse(text) as T; - } - - protected get(path: string): Promise { - return this.request('GET', path); - } - - protected post(path: string, data: object): Promise { - return this.request('POST', path, data); - } - - protected put(path: string, data: object): Promise { - return this.request('PUT', path, data); - } - - protected delete(path: string): Promise { - return this.request('DELETE', path); - } - - protected patch(path: string, data: object): Promise { - return this.request('PATCH', path, data); - } -} -``` - -### Step 5.2: Leagues API Client - -#### apps/website/lib/api/leagues/LeaguesApiClient.ts -```typescript -import { BaseApiClient } from '../base/BaseApiClient'; -import type { - LeagueSummaryDto, - LeagueStatsDto, - LeagueStandingsDto, - LeagueScheduleDto, - LeagueMembershipsDto, - CreateLeagueInputDto, - CreateLeagueOutputDto, -} from '../../dtos'; - -/** - * Leagues API client - * Handles all league-related HTTP operations - * Returns DTOs only - no UI transformation - */ -export class LeaguesApiClient extends BaseApiClient { - /** - * Get all leagues with capacity information - * @returns List of leagues with member counts - */ - async getAllWithCapacity(): Promise<{ leagues: LeagueSummaryDto[] }> { - return this.get<{ leagues: LeagueSummaryDto[] }>( - '/leagues/all-with-capacity' - ); - } - - /** - * Get total number of leagues - * @returns League statistics - */ - async getTotal(): Promise { - return this.get('/leagues/total-leagues'); - } - - /** - * Get league standings - * @param leagueId League identifier - * @returns Current standings - */ - async getStandings(leagueId: string): Promise { - return this.get(`/leagues/${leagueId}/standings`); - } - - /** - * Get league schedule - * @param leagueId League identifier - * @returns Scheduled races - */ - async getSchedule(leagueId: string): Promise { - return this.get(`/leagues/${leagueId}/schedule`); - } - - /** - * Get league memberships - * @param leagueId League identifier - * @returns Current members - */ - async getMemberships(leagueId: string): Promise { - return this.get(`/leagues/${leagueId}/memberships`); - } - - /** - * Create a new league - * @param input League creation data - * @returns Created league info - */ - async create(input: CreateLeagueInputDto): Promise { - return this.post('/leagues', input); - } - - /** - * Remove a member from league - * @param leagueId League identifier - * @param performerDriverId Driver performing the action - * @param targetDriverId Driver to remove - * @returns Success status - */ - async removeMember( - leagueId: string, - performerDriverId: string, - targetDriverId: string - ): Promise<{ success: boolean }> { - return this.patch<{ success: boolean }>( - `/leagues/${leagueId}/members/${targetDriverId}/remove`, - { performerDriverId } - ); - } -} -``` - -### Step 5.3: Races API Client - -#### apps/website/lib/api/races/RacesApiClient.ts -```typescript -import { BaseApiClient } from '../base/BaseApiClient'; -import type { - RaceStatsDto, - RacesPageDataDto, - RaceDetailDto, - RaceResultsDetailDto, - RaceWithSOFDto, - RegisterForRaceInputDto, - ImportRaceResultsInputDto, - ImportRaceResultsSummaryDto, -} from '../../dtos'; - -/** - * Races API client - * Handles all race-related HTTP operations - */ -export class RacesApiClient extends BaseApiClient { - /** - * Get total number of races - */ - async getTotal(): Promise { - return this.get('/races/total-races'); - } - - /** - * Get races page data - */ - async getPageData(): Promise { - return this.get('/races/page-data'); - } - - /** - * Get race detail - * @param raceId Race identifier - * @param driverId Driver identifier for personalization - */ - async getDetail(raceId: string, driverId: string): Promise { - return this.get(`/races/${raceId}?driverId=${driverId}`); - } - - /** - * Get race results detail - * @param raceId Race identifier - */ - async getResultsDetail(raceId: string): Promise { - return this.get(`/races/${raceId}/results`); - } - - /** - * Get race with strength of field - * @param raceId Race identifier - */ - async getWithSOF(raceId: string): Promise { - return this.get(`/races/${raceId}/sof`); - } - - /** - * Register for race - * @param raceId Race identifier - * @param input Registration data - */ - async register(raceId: string, input: RegisterForRaceInputDto): Promise { - return this.post(`/races/${raceId}/register`, input); - } - - /** - * Import race results - * @param raceId Race identifier - * @param input Results file content - */ - async importResults( - raceId: string, - input: ImportRaceResultsInputDto - ): Promise { - return this.post( - `/races/${raceId}/import-results`, - input - ); - } -} -``` - -### Step 5.4: Main API Client - -#### apps/website/lib/api/index.ts -```typescript -import { LeaguesApiClient } from './leagues/LeaguesApiClient'; -import { RacesApiClient } from './races/RacesApiClient'; -import { DriversApiClient } from './drivers/DriversApiClient'; -import { TeamsApiClient } from './teams/TeamsApiClient'; -import { SponsorsApiClient } from './sponsors/SponsorsApiClient'; -import { MediaApiClient } from './media/MediaApiClient'; -import { AnalyticsApiClient } from './analytics/AnalyticsApiClient'; -import { AuthApiClient } from './auth/AuthApiClient'; -import { PaymentsApiClient } from './payments/PaymentsApiClient'; - -/** - * Main API client with domain-specific namespaces - * Single point of access for all HTTP operations - */ -export class ApiClient { - public readonly leagues: LeaguesApiClient; - public readonly races: RacesApiClient; - public readonly drivers: DriversApiClient; - public readonly teams: TeamsApiClient; - public readonly sponsors: SponsorsApiClient; - public readonly media: MediaApiClient; - public readonly analytics: AnalyticsApiClient; - public readonly auth: AuthApiClient; - public readonly payments: PaymentsApiClient; - - constructor(baseUrl: string) { - this.leagues = new LeaguesApiClient(baseUrl); - this.races = new RacesApiClient(baseUrl); - this.drivers = new DriversApiClient(baseUrl); - this.teams = new TeamsApiClient(baseUrl); - this.sponsors = new SponsorsApiClient(baseUrl); - this.media = new MediaApiClient(baseUrl); - this.analytics = new AnalyticsApiClient(baseUrl); - this.auth = new AuthApiClient(baseUrl); - this.payments = new PaymentsApiClient(baseUrl); - } -} - -// Singleton instance -const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; -export const api = new ApiClient(API_BASE_URL); - -// Default export for convenience -export default api; -``` - -### Step 5.5: Legacy apiClient.ts Replacement - -#### apps/website/lib/apiClient.ts -```typescript -/** - * Legacy API client re-export - * Maintained for backward compatibility during migration - * TODO: Remove this file once all imports are updated - */ -export { api as apiClient, api as default } from './api'; -export type * from './dtos'; -export type * from './view-models'; -``` - -**Total API Files**: ~12 files (1 base + 9 domain + 1 main + 1 legacy) - -## Phase 6: Create Presenters (20+ Files) - -### Step 6.1: Understanding Presenter Pattern - -**Presenter Responsibilities**: -1. Transform DTO → ViewModel -2. Compute derived fields -3. Format data for display -4. Apply UI-specific logic - -**Presenter Rules**: -1. Pure functions (no side effects) -2. Deterministic (same input = same output) -3. No API calls -4. No state mutation -5. Testable in isolation - -### Step 6.2: League Presenters - -#### apps/website/lib/presenters/leagues/LeagueSummaryPresenter.ts -```typescript -import type { LeagueSummaryDto } from '../../dtos'; -import type { LeagueSummaryViewModel } from '../../view-models'; - -/** - * League summary presenter - * Transforms league DTO into UI-ready view model - */ -export const presentLeagueSummary = ( - dto: LeagueSummaryDto -): LeagueSummaryViewModel => { - const capacityPercent = (dto.memberCount / dto.maxMembers) * 100; - - return { - ...dto, - formattedCapacity: `${dto.memberCount}/${dto.maxMembers}`, - capacityBarPercent: Math.min(capacityPercent, 100), - joinButtonLabel: getJoinButtonLabel(dto), - isFull: dto.memberCount >= dto.maxMembers, - isJoinable: dto.isPublic && dto.memberCount < dto.maxMembers, - memberProgressColor: getMemberProgressColor(capacityPercent), - statusBadgeVariant: getStatusBadgeVariant(dto.status), - }; -}; - -/** - * Determine join button label based on league state - */ -function getJoinButtonLabel(dto: LeagueSummaryDto): string { - if (dto.memberCount >= dto.maxMembers) return 'Full'; - if (!dto.isPublic) return 'Private'; - return 'Join League'; -} - -/** - * Determine progress bar color based on capacity - */ -function getMemberProgressColor(percent: number): 'green' | 'yellow' | 'red' { - if (percent < 70) return 'green'; - if (percent < 90) return 'yellow'; - return 'red'; -} - -/** - * Determine status badge variant - */ -function getStatusBadgeVariant( - status?: string -): 'success' | 'warning' | 'info' { - if (!status) return 'info'; - if (status === 'active') return 'success'; - if (status === 'pending') return 'warning'; - return 'info'; -} - -/** - * Batch presenter for league lists - */ -export const presentLeagueSummaries = ( - dtos: LeagueSummaryDto[] -): LeagueSummaryViewModel[] => { - return dtos.map(presentLeagueSummary); -}; -``` - -#### apps/website/lib/presenters/leagues/LeagueStandingsPresenter.ts -```typescript -import type { StandingEntryDto, DriverDto } from '../../dtos'; -import type { - StandingEntryViewModel, - LeagueStandingsViewModel, -} from '../../view-models'; - -/** - * Single standings entry presenter - */ -export const presentStandingEntry = ( - dto: StandingEntryDto, - leaderPoints: number, - previousPoints: number, - isCurrentUser: boolean -): StandingEntryViewModel => { - return { - ...dto, - positionBadge: getPositionBadge(dto.position), - pointsGapToLeader: leaderPoints - dto.points, - pointsGapToNext: previousPoints - dto.points, - isCurrentUser, - trend: getTrend(dto.position), // Would need historical data - trendArrow: getTrendArrow(dto.position), - }; -}; - -/** - * Complete standings presenter - */ -export const presentLeagueStandings = ( - standings: StandingEntryDto[], - currentUserId?: string -): LeagueStandingsViewModel => { - const sorted = [...standings].sort((a, b) => a.position - b.position); - const leaderPoints = sorted[0]?.points ?? 0; - - const viewModels = sorted.map((entry, index) => { - const previousPoints = index > 0 ? sorted[index - 1].points : leaderPoints; - const isCurrentUser = entry.driverId === currentUserId; - return presentStandingEntry(entry, leaderPoints, previousPoints, isCurrentUser); - }); - - return { - standings: viewModels, - totalEntries: standings.length, - currentUserPosition: viewModels.find((s) => s.isCurrentUser)?.position, - }; -}; - -function getPositionBadge( - position: number -): 'gold' | 'silver' | 'bronze' | 'default' { - if (position === 1) return 'gold'; - if (position === 2) return 'silver'; - if (position === 3) return 'bronze'; - return 'default'; -} - -function getTrend(position: number): 'up' | 'down' | 'stable' { - // Placeholder - would need historical data - return 'stable'; -} - -function getTrendArrow(position: number): '↑' | '↓' | '→' { - const trend = getTrend(position); - if (trend === 'up') return '↑'; - if (trend === 'down') return '↓'; - return '→'; -} -``` - -### Step 6.3: Race Presenters - -#### apps/website/lib/presenters/races/RaceResultsPresenter.ts -```typescript -import type { RaceResultRowDto, RaceResultsDetailDto } from '../../dtos'; -import type { - RaceResultViewModel, - RaceResultsDetailViewModel, -} from '../../view-models'; - -/** - * Single race result presenter - */ -export const presentRaceResult = ( - dto: RaceResultRowDto, - fastestLapTime: number, - isCurrentUser: boolean -): RaceResultViewModel => { - const positionChange = dto.position - dto.startPosition; - - return { - driverId: dto.driverId, - driverName: '', // Would be populated from driver data - avatarUrl: '', - position: dto.position, - startPosition: dto.startPosition, - incidents: dto.incidents, - fastestLap: dto.fastestLap, - - // Computed fields - positionChange, - positionChangeDisplay: formatPositionChange(positionChange), - positionChangeColor: getPositionChangeColor(positionChange), - - // Status flags - isPodium: dto.position <= 3, - isWinner: dto.position === 1, - isClean: dto.incidents === 0, - hasFastestLap: dto.fastestLap === fastestLapTime, - - // Display helpers - positionBadge: getPositionBadge(dto.position), - incidentsBadgeColor: getIncidentsBadgeColor(dto.incidents), - lapTimeFormatted: formatLapTime(dto.fastestLap), - }; -}; - -/** - * Complete race results presenter - */ -export const presentRaceResultsDetail = ( - dto: RaceResultsDetailDto, - currentUserId?: string -): RaceResultsDetailViewModel => { - const fastestLapTime = Math.min(...dto.results.map((r) => r.fastestLap)); - - const results = dto.results.map((r) => - presentRaceResult(r, fastestLapTime, r.driverId === currentUserId) - ); - - const sortedByPosition = [...results].sort((a, b) => a.position - b.position); - const sortedByFastestLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap); - const cleanDrivers = results.filter((r) => r.isClean); - - const currentUserResult = results.find((r) => r.driverId === currentUserId); - - return { - raceId: dto.raceId, - track: dto.track, - results, - - stats: { - totalFinishers: results.length, - podiumFinishers: results.filter((r) => r.isPodium).length, - cleanRaces: cleanDrivers.length, - averageIncidents: - results.reduce((sum, r) => sum + r.incidents, 0) / results.length, - fastestLapTime, - fastestLapDriver: - sortedByFastestLap[0]?.driverName ?? 'Unknown', - }, - - resultsByPosition: sortedByPosition, - resultsByFastestLap: sortedByFastestLap, - cleanDrivers, - - currentUserResult, - currentUserHighlighted: !!currentUserResult, - }; -}; - -function formatPositionChange(change: number): string { - if (change > 0) return `+${change}`; - return change.toString(); -} - -function getPositionChangeColor( - change: number -): 'green' | 'red' | 'gray' { - if (change > 0) return 'green'; - if (change < 0) return 'red'; - return 'gray'; -} - -function getPositionBadge( - position: number -): 'gold' | 'silver' | 'bronze' | 'default' { - if (position === 1) return 'gold'; - if (position === 2) return 'silver'; - if (position === 3) return 'bronze'; - return 'default'; -} - -function getIncidentsBadgeColor( - incidents: number -): 'green' | 'yellow' | 'red' { - if (incidents === 0) return 'green'; - if (incidents <= 4) return 'yellow'; - return 'red'; -} - -function formatLapTime(milliseconds: number): string { - const minutes = Math.floor(milliseconds / 60000); - const seconds = Math.floor((milliseconds % 60000) / 1000); - const ms = milliseconds % 1000; - return `${minutes}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`; -} -``` - -### Step 6.4: Driver Presenters - -#### apps/website/lib/presenters/drivers/DriverLeaderboardPresenter.ts -```typescript -import type { DriverLeaderboardItemDto } from '../../dtos'; -import type { - DriverLeaderboardItemViewModel, - DriversLeaderboardViewModel, -} from '../../view-models'; - -/** - * Single leaderboard entry presenter - */ -export const presentDriverLeaderboardItem = ( - dto: DriverLeaderboardItemDto, - position: number -): DriverLeaderboardItemViewModel => { - const winRate = dto.races > 0 ? (dto.wins / dto.races) * 100 : 0; - - return { - ...dto, - skillLevel: getSkillLevel(dto.rating), - skillLevelColor: getSkillLevelColor(dto.rating), - skillLevelIcon: getSkillLevelIcon(dto.rating), - winRate, - winRateFormatted: `${winRate.toFixed(1)}%`, - ratingTrend: 'stable', // Would need historical data - ratingChangeIndicator: '+0', // Would need historical data - position, - positionBadge: getPositionBadge(position), - }; -}; - -/** - * Complete leaderboard presenter - */ -export const presentDriversLeaderboard = ( - dtos: DriverLeaderboardItemDto[], - page: number = 1, - pageSize: number = 50 -): DriversLeaderboardViewModel => { - const sorted = [...dtos].sort((a, b) => b.rating - a.rating); - const drivers = sorted.map((dto, index) => - presentDriverLeaderboardItem(dto, index + 1) - ); - - return { - drivers, - totalDrivers: dtos.length, - currentPage: page, - pageSize, - hasMore: false, // Would be based on actual pagination - }; -}; - -function getSkillLevel(rating: number): string { - if (rating >= 2000) return 'Pro'; - if (rating >= 1500) return 'Advanced'; - if (rating >= 1000) return 'Intermediate'; - return 'Rookie'; -} - -function getSkillLevelColor(rating: number): string { - if (rating >= 2000) return 'purple'; - if (rating >= 1500) return 'blue'; - if (rating >= 1000) return 'green'; - return 'gray'; -} - -function getSkillLevelIcon(rating: number): string { - if (rating >= 2000) return '⭐'; - if (rating >= 1500) return '🔷'; - if (rating >= 1000) return '🟢'; - return '⚪'; -} - -function getPositionBadge( - position: number -): 'gold' | 'silver' | 'bronze' | 'default' { - if (position === 1) return 'gold'; - if (position === 2) return 'silver'; - if (position === 3) return 'bronze'; - return 'default'; -} -``` - -### Step 6.5: Barrel Export (apps/website/lib/presenters/index.ts) - -```typescript -// Leagues -export * from './leagues/LeagueSummaryPresenter'; -export * from './leagues/LeagueStandingsPresenter'; - -// Races -export * from './races/RaceResultsPresenter'; - -// Drivers -export * from './drivers/DriverLeaderboardPresenter'; - -// Teams, etc. -``` - -**Total Presenter Files**: ~20-25 files - -## Phase 7: Create Services (15+ Files) - -### Step 7.1: Understanding Service Pattern - -**Service Responsibilities**: -1. Orchestrate API calls -2. Call presenters for transformation -3. Combine multiple data sources -4. Return ViewModels to UI -5. Handle errors appropriately - -**Service Rules**: -1. May call multiple API endpoints -2. Must use presenters for DTO→ViewModel -3. Return ViewModels only (never DTOs) -4. May have async operations -5. May throw/handle errors - -### Step 7.2: Race Services - -#### apps/website/lib/services/races/RaceResultsService.ts -```typescript -import { api } from '../../api'; -import { presentRaceResultsDetail } from '../../presenters/races/RaceResultsPresenter'; -import type { RaceResultsDetailViewModel } from '../../view-models'; - -/** - * Get race results with full view model - * @param raceId Race identifier - * @param currentUserId Optional current user for highlighting - * @returns Complete race results view model - */ -export async function getRaceResults( - raceId: string, - currentUserId?: string -): Promise { - const dto = await api.races.getResultsDetail(raceId); - return presentRaceResultsDetail(dto, currentUserId); -} - -/** - * Get race strength of field - * @param raceId Race identifier - * @returns SOF value - */ -export async function getRaceSOF(raceId: string): Promise { - const dto = await api.races.getWithSOF(raceId); - return dto.strengthOfField ?? 0; -} - -/** - * Import race results and refresh - * @param raceId Race identifier - * @param fileContent Results file content - * @returns Import summary - */ -export async function importRaceResults( - raceId: string, - fileContent: string -): Promise<{ success: boolean; message: string }> { - try { - const summary = await api.races.importResults(raceId, { - resultsFileContent: fileContent, - }); - - return { - success: summary.success, - message: `Imported ${summary.resultsRecorded} results for ${summary.driversProcessed} drivers`, - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Import failed', - }; - } -} -``` - -### Step 7.3: League Services - -#### apps/website/lib/services/leagues/LeagueService.ts -```typescript -import { api } from '../../api'; -import { - presentLeagueSummaries, - presentLeagueStandings, -} from '../../presenters'; -import type { - LeagueSummaryViewModel, - LeagueStandingsViewModel, -} from '../../view-models'; -import type { CreateLeagueInputDto } from '../../dtos'; - -/** - * Get all leagues with UI-ready data - * @returns List of league view models - */ -export async function getAllLeagues(): Promise { - const dto = await api.leagues.getAllWithCapacity(); - return presentLeagueSummaries(dto.leagues); -} - -/** - * Get league standings with computed data - * @param leagueId League identifier - * @param currentUserId Optional current user for highlighting - * @returns Standings view model - */ -export async function getLeagueStandings( - leagueId: string, - currentUserId?: string -): Promise { - const dto = await api.leagues.getStandings(leagueId); - return presentLeagueStandings(dto.standings, currentUserId); -} - -/** - * Create a new league - * @param input League creation data - * @returns Created league ID - */ -export async function createLeague( - input: Omit, - ownerId: string -): Promise { - const result = await api.leagues.create({ ...input, ownerId }); - - if (!result.success) { - throw new Error('Failed to create league'); - } - - return result.leagueId; -} - -/** - * Get complete league admin view - * Combines multiple API calls - */ -export async function getLeagueAdminView( - leagueId: string, - performerId: string -) { - const [config, members, standings, schedule] = await Promise.all([ - api.leagues.getConfig(leagueId), - api.leagues.getMemberships(leagueId), - api.leagues.getStandings(leagueId), - api.leagues.getSchedule(leagueId), - ]); - - return { - config, - members: members.members, - standings: presentLeagueStandings(standings.standings, performerId), - schedule: schedule.races, - }; -} -``` - -### Step 7.4: Driver Services - -#### apps/website/lib/services/drivers/DriverService.ts -```typescript -import { api } from '../../api'; -import { presentDriversLeaderboard } from '../../presenters'; -import type { DriversLeaderboardViewModel } from '../../view-models'; -import type { CompleteOnboardingInputDto } from '../../dtos'; - -/** - * Get driver leaderboard with computed rankings - * @returns Leaderboard view model - */ -export async function getDriversLeaderboard(): Promise { - const dto = await api.drivers.getLeaderboard(); - return presentDriversLeaderboard(dto.drivers); -} - -/** - * Complete driver onboarding - * @param iracingId iRacing ID - * @param displayName Display name - * @returns New driver ID - */ -export async function completeDriverOnboarding( - iracingId: string, - displayName: string -): Promise { - const input: CompleteOnboardingInputDto = { iracingId, displayName }; - const result = await api.drivers.completeOnboarding(input); - - if (!result.success) { - throw new Error('Failed to complete onboarding'); - } - - return result.driverId; -} - -/** - * Get current driver info - * @returns Current driver or null - */ -export async function getCurrentDriver() { - return api.drivers.getCurrent(); -} -``` - -### Step 7.5: Barrel Export (apps/website/lib/services/index.ts) - -```typescript -// Races -export * from './races/RaceResultsService'; - -// Leagues -export * from './leagues/LeagueService'; - -// Drivers -export * from './drivers/DriverService'; - -// Teams, etc. -``` - -**Total Service Files**: ~15-20 files - -## Phase 8: Update Pages (All app/ Pages) - -### Step 8.1: Update races/[id]/results/page.tsx - -**Before (lines 1-300)**: -```typescript -'use client'; - -import { useState, useEffect } from 'react'; -import { apiClient } from '@/lib/apiClient'; -import type { RaceResultsDetailViewModel } from '@/lib/apiClient'; - -// Inline DTOs (DELETE THESE) -type PenaltyTypeDTO = 'time_penalty' | 'grid_penalty' | ...; -interface PenaltyData { ... } -interface RaceResultRowDTO { ... } - -export default function RaceResultsPage() { - const [raceData, setRaceData] = useState(null); - - const loadData = async () => { - const data = await apiClient.races.getResultsDetail(raceId); - setRaceData(data); - }; - - // ... -} -``` - -**After**: -```typescript -'use client'; - -import { useState, useEffect } from 'react'; -import { getRaceResults, getRaceSOF } from '@/lib/services/races/RaceResultsService'; -import type { RaceResultsDetailViewModel } from '@/lib/view-models'; - -// No inline DTOs! - -export default function RaceResultsPage() { - const [raceData, setRaceData] = useState(null); - const [raceSOF, setRaceSOF] = useState(null); - - const loadData = async () => { - try { - // Use service, not apiClient - const data = await getRaceResults(raceId, currentDriverId); - setRaceData(data); - - const sof = await getRaceSOF(raceId); - setRaceSOF(sof); - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to load data'); - } - }; - - // Component now uses ViewModel fields: - // - raceData.stats.totalFinishers - // - raceData.stats.podiumFinishers - // - raceData.currentUserResult?.positionBadge - // - raceData.resultsByPosition -} -``` - -### Step 8.2: Update leagues/[id]/standings/page.tsx - -```typescript -'use client'; - -import { useState, useEffect } from 'react'; -import { getLeagueStandings } from '@/lib/services/leagues/LeagueService'; -import type { LeagueStandingsViewModel } from '@/lib/view-models'; - -export default function LeagueStandingsPage() { - const [standings, setStandings] = useState(null); - - useEffect(() => { - async function loadStandings() { - const data = await getLeagueStandings(leagueId, currentUserId); - setStandings(data); - } - loadStandings(); - }, [leagueId, currentUserId]); - - return ( -
- {standings?.standings.map((entry) => ( -
- {entry.positionBadge} - {entry.driver?.name} - {entry.points} - {entry.trendArrow} - {entry.pointsGapToLeader} behind -
- ))} -
- ); -} -``` - -### Step 8.3: Update drivers/leaderboard/page.tsx - -```typescript -'use client'; - -import { useState, useEffect } from 'react'; -import { getDriversLeaderboard } from '@/lib/services/drivers/DriverService'; -import type { DriversLeaderboardViewModel } from '@/lib/view-models'; - -export default function DriversLeaderboardPage() { - const [leaderboard, setLeaderboard] = useState(null); - - useEffect(() => { - async function loadLeaderboard() { - const data = await getDriversLeaderboard(); - setLeaderboard(data); - } - loadLeaderboard(); - }, []); - - return ( -
- {leaderboard?.drivers.map((driver) => ( -
- {driver.position} - {driver.positionBadge} - {driver.name} - - {driver.skillLevel} {driver.skillLevelIcon} - - {driver.rating} - {driver.winRateFormatted} -
- ))} -
- ); -} -``` - -### Step 8.4: Search & Replace Pattern - -```bash -# Find all apiClient direct imports -grep -r "from '@/lib/apiClient'" apps/website/app/ - -# Find all pages with inline type definitions -grep -r "^type \|^interface " apps/website/app/**/*.tsx - -# Replace pattern (manual review required): -# 1. Import from services, not apiClient -# 2. Import types from view-models, not dtos -# 3. Remove inline types -# 4. Use service functions -# 5. Use ViewModel fields in JSX -``` - -**Pages to Update** (estimated 15-20): -- races/page.tsx -- races/[id]/page.tsx -- races/[id]/results/page.tsx -- leagues/page.tsx -- leagues/[id]/page.tsx -- leagues/[id]/standings/page.tsx -- leagues/[id]/members/page.tsx -- drivers/leaderboard/page.tsx -- teams/page.tsx -- teams/[id]/page.tsx -- onboarding/page.tsx -- dashboard/page.tsx -- profile/settings/page.tsx - -## Phase 9: Barrels & Naming Enforcement - -### Step 9.1: Final Barrel Exports - -All index.ts files should follow this pattern: - -```typescript -// apps/website/lib/dtos/index.ts -// Export all DTOs alphabetically by domain - -// Common -export * from './DriverDto'; -export * from './PenaltyDataDto'; -export * from './PenaltyTypeDto'; - -// Analytics -export * from './RecordEngagementInputDto'; -export * from './RecordEngagementOutputDto'; -export * from './RecordPageViewInputDto'; -export * from './RecordPageViewOutputDto'; - -// Auth -export * from './LoginParamsDto'; -export * from './SessionDataDto'; -export * from './SignupParamsDto'; - -// (Continue for all domains...) -``` - -### Step 9.2: Naming Convention Audit - -**Checklist**: -- [ ] All DTO files end with `Dto.ts` -- [ ] All ViewModel files end with `ViewModel.ts` -- [ ] All Presenter files end with `Presenter.ts` -- [ ] All Service files end with `Service.ts` -- [ ] All files are PascalCase -- [ ] All exports match filename -- [ ] One export per file - -**Automated Check**: -```bash -# Find files not following naming convention -find apps/website/lib/dtos -type f ! -name "*Dto.ts" ! -name "index.ts" -find apps/website/lib/view-models -type f ! -name "*ViewModel.ts" ! -name "index.ts" -find apps/website/lib/presenters -type f ! -name "*Presenter.ts" ! -name "index.ts" -find apps/website/lib/services -type f ! -name "*Service.ts" ! -name "index.ts" -``` - -## Phase 10: Enforcement & Validation - -### Step 10.1: ESLint Rules - -#### .eslintrc.json additions -```json -{ - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["**/apiClient"], - "message": "Import from specific services instead of apiClient" - }, - { - "group": ["**/dtos"], - "message": "UI components should not import DTOs directly. Use ViewModels instead." - }, - { - "group": ["**/api/*"], - "message": "UI components should use services, not API clients directly" - } - ] - } - ] - } -} -``` - -### Step 10.2: TypeScript Path Mappings - -#### tsconfig.json additions -```json -{ - "compilerOptions": { - "paths": { - "@/lib/dtos": ["./apps/website/lib/dtos"], - "@/lib/view-models": ["./apps/website/lib/view-models"], - "@/lib/presenters": ["./apps/website/lib/presenters"], - "@/lib/services": ["./apps/website/lib/services"], - "@/lib/api": ["./apps/website/lib/api"] - } - } -} -``` - -### Step 10.3: DATA_FLOW.md Mermaid Diagram - -Add to DATA_FLOW.md: - -```markdown -## Architecture Diagram - -```mermaid -graph TD - UI[UI Components/Pages] --> Services[Services Layer] - Services --> API[API Clients] - Services --> Presenters[Presenters Layer] - API --> DTOs[DTOs Transport] - Presenters --> DTOs - Presenters --> ViewModels[ViewModels UI] - Services --> ViewModels - UI --> ViewModels - - style UI fill:#e1f5ff - style Services fill:#fff4e1 - style Presenters fill:#f0e1ff - style API fill:#e1ffe1 - style DTOs fill:#ffe1e1 - style ViewModels fill:#e1f5ff -``` - -**Dependency Rules**: -- ✅ UI → Services → (API + Presenters) → (DTOs + ViewModels) -- ❌ UI ↛ API -- ❌ UI ↛ DTOs -- ❌ Presenters ↛ API -- ❌ API ↛ ViewModels -``` - -### Step 10.4: Testing - -#### Unit Test Example: Presenter -```typescript -// apps/website/lib/presenters/races/RaceResultsPresenter.test.ts -import { presentRaceResult } from './RaceResultsPresenter'; -import type { RaceResultRowDto } from '../../dtos'; - -describe('presentRaceResult', () => { - it('should compute position change correctly', () => { - const dto: RaceResultRowDto = { - id: '1', - raceId: 'race-1', - driverId: 'driver-1', - position: 3, - startPosition: 8, - fastestLap: 90000, - incidents: 0, - }; - - const result = presentRaceResult(dto, 89000, false); - - expect(result.positionChange).toBe(-5); - expect(result.positionChangeDisplay).toBe('+5'); - expect(result.positionChangeColor).toBe('green'); - }); - - it('should identify podium finishes', () => { - const dto: RaceResultRowDto = { - id: '1', - raceId: 'race-1', - driverId: 'driver-1', - position: 2, - startPosition: 2, - fastestLap: 90000, - incidents: 0, - }; - - const result = presentRaceResult(dto, 89000, false); - - expect(result.isPodium).toBe(true); - expect(result.positionBadge).toBe('silver'); - }); -}); -``` - -#### Integration Test Example: Service -```typescript -// apps/website/lib/services/races/RaceResultsService.test.ts -import { getRaceResults } from './RaceResultsService'; -import { api } from '../../api'; - -jest.mock('../../api'); - -describe('getRaceResults', () => { - it('should return view model with computed fields', async () => { - const mockDto = { - raceId: 'race-1', - track: 'Spa', - results: [ - { - id: '1', - raceId: 'race-1', - driverId: 'driver-1', - position: 1, - startPosition: 3, - fastestLap: 89000, - incidents: 0, - }, - ], - }; - - (api.races.getResultsDetail as jest.Mock).mockResolvedValue(mockDto); - - const result = await getRaceResults('race-1'); - - expect(result.stats.totalFinishers).toBe(1); - expect(result.stats.podiumFinishers).toBe(1); - expect(result.resultsByPosition).toHaveLength(1); - expect(result.resultsByPosition[0].positionBadge).toBe('gold'); - }); -}); -``` - -### Step 10.5: Verification Checklist - -**Final Checklist**: -- [ ] All 60+ DTO files created -- [ ] All 30+ ViewModel files created -- [ ] All 10+ API client files created -- [ ] All 20+ Presenter files created -- [ ] All 15+ Service files created -- [ ] All pages updated to use services -- [ ] No inline DTOs in pages -- [ ] All barrel exports complete -- [ ] ESLint rules enforced -- [ ] TypeScript compiles -- [ ] All tests passing -- [ ] DATA_FLOW.md updated -- [ ] Documentation complete -- [ ] Original apiClient.ts marked deprecated - -**Build Verification**: -```bash -# Ensure clean build -npm run build - -# Run all tests -npm run test - -# ESLint check -npm run lint - -# Type check -npm run type-check -``` - -## Summary - -**Total Changes**: -- **Files Created**: ~150 -- **Files Modified**: ~20 pages -- **Files Deleted**: None (apiClient.ts kept for compatibility) -- **Lines of Code**: +8000, -1160 (apiClient.ts) -- **apiClient.ts Size Reduction**: 95% -- **Architecture Compliance**: 100% - -**Benefits**: -1. ✅ Strict layer separation -2. ✅ No inline DTOs -3. ✅ Reusable ViewModels -4. ✅ Testable presenters -5. ✅ Clear data flow -6. ✅ Maintainable structure -7. ✅ Type safety -8. ✅ Enforced conventions \ No newline at end of file diff --git a/plans/MEDIA_ARCHITECTURE_COMPLETE_ANALYSIS.md b/plans/MEDIA_ARCHITECTURE_COMPLETE_ANALYSIS.md deleted file mode 100644 index 2c18a78dc..000000000 --- a/plans/MEDIA_ARCHITECTURE_COMPLETE_ANALYSIS.md +++ /dev/null @@ -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 { - 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 │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ 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 { - 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 { - // ✅ 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(); - private teamLogos = new Map(); - - 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. \ No newline at end of file diff --git a/plans/UNIFIED_LOGGING_PLAN.md b/plans/UNIFIED_LOGGING_PLAN.md deleted file mode 100644 index 49fd1d13f..000000000 --- a/plans/UNIFIED_LOGGING_PLAN.md +++ /dev/null @@ -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): 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! 🎨 \ No newline at end of file diff --git a/plans/admin-area-architecture.md b/plans/admin-area-architecture.md deleted file mode 100644 index 46b2b0285..000000000 --- a/plans/admin-area-architecture.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plans/api-usecase-presenter-migration.md b/plans/api-usecase-presenter-migration.md deleted file mode 100644 index 49eadd5ed..000000000 --- a/plans/api-usecase-presenter-migration.md +++ /dev/null @@ -1,517 +0,0 @@ -# API Use Case and Presenter Migration Todo List (Per File) - -This todo list is structured per domain module and per file. It is intentionally free of code snippets and focuses only on the structural and behavioral changes required. - ---- - -## Global cross-cutting tasks - -- [ ] Ensure every migrated use case in the core returns a Result type and uses an output port to present its business result model. -- [ ] Ensure all presenters live in the API layer, receive business result models from use cases via an output port, and store internal response models for the API. -- [ ] Ensure all services in the API layer delegate mapping and response model construction to presenters and do not perform DTO or response model mapping themselves. -- [ ] Ensure repositories and use cases are injected via dependency injection tokens only, never by direct class references. -- [ ] Ensure presenters are never injected into services via dependency injection; presenters should be imported directly where needed and bound as output ports in modules. -- [ ] Ensure use cases never perform serialization or DTO mapping; use cases operate on domain objects and result models only. -- [ ] After each module migration, run type checking, linting, and targeted tests for that module. -- [ ] After all modules are migrated, run full type checking, linting, and the entire test suite. - ---- - -## Analytics domain module - -Directory: apps/api/src/domain/analytics - -### Controllers - -- File: apps/api/src/domain/analytics/AnalyticsController.ts - - [x] Review all controller methods and update them to consume response models returned from the analytics service rather than constructing or interpreting DTOs manually. - - [x] Ensure controller method signatures and return types reflect the new response model naming and structure introduced by presenters. - -- File: apps/api/src/domain/analytics/AnalyticsController.test.ts - - [x] Update tests to assert that controller methods receive response models from the service and do not depend on internal mapping logic inside the service. - - [x] Adjust expectations to align with response model terminology instead of any previous view model terminology. - -### Services - -- File: apps/api/src/domain/analytics/AnalyticsService.ts - - [x] Identify each method that calls a core analytics use case and ensure it passes the use case result through the appropriate presenter via the output port. - - [x] Remove any mapping or DTO-building logic from the service methods; move all such responsibilities into dedicated analytics presenters. - - [x] Ensure each service method returns only the presenter’s response model, not any core domain objects or intermediate data. - - [x] Verify that all injected dependencies are repositories and use cases injected via tokens, with no presenters injected via dependency injection. - -### Module and providers - -- File: apps/api/src/domain/analytics/AnalyticsModule.ts - - [x] Ensure the module declares analytics presenters as providers and binds them as implementations of the generic output port for the relevant use cases. - - [x] Ensure that services and controllers are wired to use analytics use cases via tokens, not via direct class references. - -- File: apps/api/src/domain/analytics/AnalyticsModule.test.ts - - [x] Update module-level tests to reflect the new provider wiring, especially the binding of presenters as output ports for use cases. - -- File: apps/api/src/domain/analytics/AnalyticsProviders.ts - - [x] Ensure all analytics repositories and use cases are exposed via clear token constants and that these tokens are used consistently in service constructors. - - [x] Add or adjust any tokens required for use case output port injection, without introducing presenter tokens for services. - -### DTOs - -- Files: apps/api/src/domain/analytics/dtos/GetAnalyticsMetricsOutputDTO.ts, GetDashboardDataOutputDTO.ts, RecordEngagementInputDTO.ts, RecordEngagementOutputDTO.ts, RecordPageViewInputDTO.ts, RecordPageViewOutputDTO.ts - - [x] Verify that all analytics DTOs represent API-level input or response models only and are not used directly inside core use cases. - - [x] Ensure naming reflects response model terminology where applicable and is consistent with presenters. - -### Presenters - -- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts, GetDashboardDataPresenter.ts, RecordEngagementPresenter.ts, RecordPageViewPresenter.ts - - [x] For each presenter, ensure it implements the use case output port contract for the corresponding analytics result model. - - [x] Ensure each presenter maintains internal response model state that is constructed from the core result model. - - [x] Ensure each presenter exposes a getter that returns the response model used by controllers or services. - - [x] Move any analytics-specific mapping and transformation from services into these presenters. - - [x] Align terminology within presenters to use response model rather than view model. - -- Files: apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.test.ts, GetDashboardDataPresenter.test.ts, RecordEngagementPresenter.test.ts, RecordPageViewPresenter.test.ts - - [x] Update tests to validate that each presenter receives a core result model, transforms it correctly, and exposes the correct response model. - - [x] Ensure tests no longer assume mapping occurs in services; all mapping assertions should target presenter behavior. - ---- - -## Auth domain module - -Directory: apps/api/src/domain/auth - -### Controllers - -- File: apps/api/src/domain/auth/AuthController.ts - - [x] Review all controller methods and ensure they consume response models returned from the auth service, not raw domain objects or DTOs assembled by the controller. - - [x] Align controller return types with the response models produced by auth presenters. - -- File: apps/api/src/domain/auth/AuthController.test.ts - - [x] Update tests so they verify the controller’s interaction with the auth service in terms of response models and error handling consistent with use case Results. - -### Services - -- File: apps/api/src/domain/auth/AuthService.ts - - [x] For signup, login, and logout operations, ensure the service only coordinates input, calls the corresponding core use cases, and retrieves response models from auth presenters. - - [x] Remove all mapping logic in the service that translates between core user or session representations and API DTOs; move this logic into dedicated presenters. - - [x] Ensure use cases are injected via tokens and that repositories and ports also use token-based injection. - - [x] Ensure presenters are not injected into the service via dependency injection and are instead treated as part of the output port wiring and imported where necessary. - - [x] Ensure each public service method returns a response model based on presenter state, not core domain entities. - -### Module and providers - -- File: apps/api/src/domain/auth/AuthModule.ts - - [x] Ensure the module declares auth presenters as providers and wires them as implementations of the use case output port for login, signup, and logout use cases. - - [x] Confirm that the auth service and controller depend on use cases and ports via the defined tokens. - -- File: apps/api/src/domain/auth/AuthModule.test.ts - - [x] Update module tests to reflect the new wiring of auth presenters as output ports and the absence of presenter injection into services. - -- File: apps/api/src/domain/auth/AuthProviders.ts - - [x] Verify that all tokens for auth repositories, services, and use cases are defined and consistently used. - - [x] Add or adjust tokens required for output port injection, ensuring presenters themselves are not injected into services. - -### DTOs - -- File: apps/api/src/domain/auth/dtos/AuthDto.ts - - [x] Ensure DTOs in this file represent API-level input and response models only and are not referenced by core use cases. - - [x] Align DTO naming with response model terminology where applicable. - -### Presenters - -- Files: apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts, CommandResultPresenter.ts - - [x] Ensure each presenter implements the generic use case output port contract for the relevant auth result model. - - [x] Ensure each presenter maintains internal response model state derived from the core result model. - - [x] Ensure a getter method is available to expose the response model to controllers and services. - - [x] Move all auth-related mapping logic from the auth service into these presenters. - - [x] Normalize terminology within presenters to use response model instead of view model. - -- File: apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts - - [x] Update tests so they validate that the auth session presenter receives core result models from auth use cases and correctly transforms them into auth response models. - ---- - -## Dashboard domain module - -Directory: apps/api/src/domain/dashboard - -### Controllers - -- File: apps/api/src/domain/dashboard/DashboardController.ts - - [x] Ensure controller methods depend on dashboard service methods that return response models, not core objects or partial mappings. - - [x] Align method return types and expectations with the dashboard response models built by presenters. - -- File: apps/api/src/domain/dashboard/DashboardController.test.ts - - [x] Update tests to assert that the controller interacts with the service in terms of response models, not internal mapping behavior. - -### Services - -- File: apps/api/src/domain/dashboard/DashboardService.ts - - [x] Identify all dashboard service methods that construct or manipulate DTOs directly and move this logic into dashboard presenters. - - [x] Ensure each service method calls the appropriate dashboard use case, allows it to drive presenters through output ports, and returns a response model obtained from presenters. - - [x] Confirm that dashboard use cases and repositories are injected via tokens, with no presenters injected via dependency injection. - -### Module and providers - -- File: apps/api/src/domain/dashboard/DashboardModule.ts - - [x] Ensure the module binds dashboard presenters as output port implementations for the relevant use cases. - - [x] Ensure dashboard services depend on use cases via tokens only. - -- File: apps/api/src/domain/dashboard/DashboardModule.test.ts - - [x] Adjust tests to confirm correct provider wiring of presenters as output ports. - -- File: apps/api/src/domain/dashboard/DashboardProviders.ts - - [x] Review token definitions for repositories, services, and use cases; ensure all are used consistently in constructor injection. - - [x] Add or adjust any tokens needed for output port wiring. - -### DTOs - -- Files: apps/api/src/domain/dashboard/dtos/DashboardDriverSummaryDTO.ts, DashboardFeedItemSummaryDTO.ts, DashboardRaceSummaryDTO.ts - - [x] Verify that these DTOs are used only as API-level response models from presenters or services and not within core use cases. - - [x] Align naming and fields with the response models produced by dashboard presenters. - -### Presenters - -- (Any dashboard presenters, when added or identified in the codebase) - - [x] Ensure they implement the use case output port contract, maintain internal response model state, and expose response model getters. - - [x] Move all dashboard mapping and DTO-building logic into these presenters. - ---- - -## Driver domain module - -Directory: apps/api/src/domain/driver - -### Controllers - -- File: apps/api/src/domain/driver/DriverController.ts - - [x] Ensure controller methods depend on driver service methods that return response models, not domain entities or partial DTOs. - - [x] Align method signatures and return types with driver response models provided by presenters. - -- File: apps/api/src/domain/driver/DriverController.test.ts - - [x] Update tests so they verify controller interactions with the driver service via response models and error handling consistent with use case Results. - -### Services - -- File: apps/api/src/domain/driver/DriverService.ts - - [x] Identify all mapping logic from driver domain objects to DTOs in the service and move that logic into driver presenters. - - [x] Ensure each service method calls the relevant driver use case, lets the use case present results through presenters, and returns response models obtained from presenters. - - [x] Confirm that repositories and use cases are injected via tokens, not via direct class references. - - [x] Ensure no presenter is injected into the driver service via dependency injection. - -### Module and providers - -- File: apps/api/src/domain/driver/DriverModule.ts - - [x] Ensure driver presenters are registered as providers and are bound as output port implementations for driver use cases. - - [x] Ensure the driver service and controller depend on use cases via tokens. - -- File: apps/api/src/domain/driver/DriverModule.test.ts - - [x] Update module tests to reflect the wiring of presenters as output ports and the token-based injection of use cases. - -### DTOs - -- Files: apps/api/src/domain/driver/dtos/CompleteOnboardingInputDTO.ts, CompleteOnboardingOutputDTO.ts, DriverDTO.ts, DriverLeaderboardItemDTO.ts, DriverRegistrationStatusDTO.ts, DriversLeaderboardDTO.ts, DriverStatsDTO.ts, GetDriverOutputDTO.ts, GetDriverProfileOutputDTO.ts, GetDriverRegistrationStatusQueryDTO.ts - - [x] Ensure these DTOs are used exclusively as API input or response models, and not inside core use cases. - - [x] Align names and shapes with the response models and input expectations defined in driver presenters and services. - -### Presenters - -- Files: apps/api/src/domain/driver/presenters/CompleteOnboardingPresenter.ts, DriverPresenter.ts, DriverProfilePresenter.ts, DriverRegistrationStatusPresenter.ts, DriversLeaderboardPresenter.ts, DriverStatsPresenter.ts - - [x] Ensure each presenter implements the use case output port contract for its driver result model. - - [x] Ensure each presenter maintains an internal response model that is constructed from the driver result model. - - [x] Ensure each presenter exposes a getter that returns the response model. - - [x] Move all driver mapping logic from the driver service into the relevant presenters. - - [x] Consistently use response model terminology within presenters. - -- Files: apps/api/src/domain/driver/presenters/DriverRegistrationStatusPresenter.test.ts, DriversLeaderboardPresenter.test.ts, DriverStatsPresenter.test.ts - - [x] Update tests to confirm that presenters correctly transform core result models into driver response models and that no mapping remains in the service. - ---- - -## League domain module - -Directory: apps/api/src/domain/league - -### Controllers - -- File: apps/api/src/domain/league/LeagueController.ts - - [ ] Ensure controller methods expect league response models from the league service and do not depend on internal DTO mapping logic. - - [ ] Align return types with the league response models constructed by presenters. - -- File: apps/api/src/domain/league/LeagueController.test.ts - - [ ] Update tests to verify that controllers interact with the league service via response models and handle errors based on use case Results. - -### Services - -- File: apps/api/src/domain/league/LeagueService.ts - - [ ] Identify all mapping and DTO construction logic in the league service and move it into league presenters. - - [ ] Ensure service methods delegate output handling entirely to presenters and only return response models. - - [ ] Ensure repositories and use cases are injected via tokens and that no presenter is injected via dependency injection. - -- File: apps/api/src/domain/league/LeagueService.test.ts - - [ ] Update tests to reflect the reduced responsibility of the league service and to verify that it returns response models produced by presenters. - -### Module and providers - -- (If a LeagueModule file exists in the codebase) - - [ ] Ensure league presenters are registered as providers and bound as output port implementations for league use cases. - -### DTOs - -- Files: apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts, ApproveLeagueJoinRequestDTO.ts, GetLeagueRacesOutputDTO.ts, GetLeagueWalletOutputDTO.ts, LeagueAdminDTO.ts, LeagueConfigFormModelDropPolicyDTO.ts, LeagueConfigFormModelStewardingDTO.ts, LeagueMemberDTO.ts, LeagueScoringPresetDTO.ts, MembershipStatusDTO.ts, RejectJoinRequestOutputDTO.ts, WithdrawFromLeagueWalletInputDTO.ts, WithdrawFromLeagueWalletOutputDTO.ts - - [ ] Ensure these DTOs are used only as API-level representations and not referenced by core use cases. - - [ ] Align naming with the response models produced by league presenters. - -### Presenters - -- Files: apps/api/src/domain/league/presenters/GetLeagueAdminPermissionsPresenter.ts, LeagueScoringPresetsPresenter.ts - - [ ] Ensure each presenter implements the use case output port contract for its league result model. - - [ ] Ensure each presenter constructs and stores a league response model from the core result model and exposes a getter. - - [ ] Move league mapping logic from the league service into these presenters. - -- File: apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts - - [ ] Update tests to confirm that the presenter is responsible for mapping from core result model to response model and that no mapping remains in the service. - ---- - -## Media domain module - -Directory: apps/api/src/domain/media - -### Controllers - -- File: apps/api/src/domain/media/MediaController.ts - - [x] Ensure controller methods depend on media service methods that return response models, not core media objects or partial DTOs. - - [x] Align the controller return types with media response models produced by presenters. - -- File: apps/api/src/domain/media/MediaController.test.ts - - [x] Update tests to verify that controllers work with media response models returned from the service. - -### Services - -- File: apps/api/src/domain/media/MediaService.ts - - [x] Identify all mapping from media domain objects to DTOs and move this logic into media presenters. - - [x] Ensure each service method calls the relevant media use case, allows it to use presenters via output ports, and returns response models from presenters. - - [x] Confirm that repositories and use cases are injected via tokens and that no presenter is injected via dependency injection. - -### Module and providers - -- File: apps/api/src/domain/media/MediaModule.ts - - [x] Ensure media presenters are registered as providers and bound as output port implementations for media use cases. - -- File: apps/api/src/domain/media/MediaModule.test.ts - - [x] Update tests to reflect the correct provider wiring and output port bindings. - -- File: apps/api/src/domain/media/MediaProviders.ts - - [x] Review token definitions for repositories and use cases; ensure they are used consistently for constructor injection. - - [x] Add or adjust tokens required for output port wiring without introducing presenter tokens for services. - -### DTOs - -- Files: apps/api/src/domain/media/dtos/DeleteMediaOutputDTO.ts, GetAvatarOutputDTO.ts, GetMediaOutputDTO.ts, RequestAvatarGenerationInputDTO.ts, RequestAvatarGenerationOutputDTO.ts, UpdateAvatarInputDTO.ts, UpdateAvatarOutputDTO.ts, UploadMediaInputDTO.ts, UploadMediaOutputDTO.ts - - [x] Ensure these DTOs serve only as API input and response models and are not used directly within core use cases. - - [x] Align naming and structure with the response models built by media presenters. - -### Presenters - -- Files: apps/api/src/domain/media/presenters/DeleteMediaPresenter.ts, GetAvatarPresenter.ts, GetMediaPresenter.ts, RequestAvatarGenerationPresenter.ts, UpdateAvatarPresenter.ts, UploadMediaPresenter.ts - - [x] Ensure each presenter implements the use case output port contract for its media result model. - - [x] Ensure each presenter maintains internal response model state derived from the core result model and exposes a getter. - - [x] Move all mapping and response model construction from the media service into these presenters. - -### Types - -- Files: apps/api/src/domain/media/types/FacePhotoData.ts, SuitColor.ts - - [x] Verify that these types are used appropriately as part of input or response models and not as replacements for core domain entities inside use cases. - ---- - -## Payments domain module - -Directory: apps/api/src/domain/payments - -### Controllers - -- File: apps/api/src/domain/payments/PaymentsController.ts - - [x] Ensure controller methods call payments use cases via services or directly, receive results that are presented via presenters, and return payments response models. - - [x] Remove any mapping logic from the controller and rely exclusively on presenters for transforming result models into response models. - -### Module and providers - -- File: apps/api/src/domain/payments/PaymentsModule.ts - - [x] Ensure payments presenters are registered as providers and bound as output port implementations for payments use cases. - -- File: apps/api/src/domain/payments/PaymentsModule.test.ts - - [x] Update module tests to reflect correct output port wiring and token-based use case injection. - -- File: apps/api/src/domain/payments/PaymentsProviders.ts - - [x] Review token definitions for payments repositories and use cases; ensure they are consistently used for dependency injection. - - [x] Add or adjust tokens as needed for output port wiring. - -### DTOs - -- Files: apps/api/src/domain/payments/dtos/CreatePaymentInputDTO.ts, CreatePaymentOutputDTO.ts, MemberPaymentStatus.ts, MembershipFeeType.ts, PayerType.ts, PaymentDTO.ts, PaymentsDto.ts, PaymentStatus.ts, PaymentType.ts, PrizeType.ts, ReferenceType.ts, TransactionType.ts, UpdatePaymentStatusInputDTO.ts, UpdatePaymentStatusOutputDTO.ts - - [x] Ensure these DTOs are used solely as API-level input and response models and not within core payments use cases. - - [x] Align naming and structure with the response models and inputs expected by payments presenters and services. - -### Presenters - -- (Any payments presenters, once identified or added during implementation) - - [x] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. - - [x] Centralize all payments mapping logic in these presenters. - ---- - -## Protests domain module - -Directory: apps/api/src/domain/protests - -### Controllers - -- File: apps/api/src/domain/protests/ProtestsController.ts - - [ ] Ensure controller methods rely on protests service methods that return response models, avoiding any direct mapping from domain objects. - - [ ] Align controller return types with protests response models produced by presenters. - -### Services - -- File: apps/api/src/domain/protests/ProtestsService.ts - - [ ] Identify all mapping logic in the protests service and move it into protests presenters. - - [ ] Ensure each service method calls the appropriate protests use case, lets it use presenters through output ports, and returns response models from presenters. - - [ ] Confirm that protests repositories and use cases are injected via tokens only. - - [ ] Ensure no protests presenter is injected into the service via dependency injection. - -- File: apps/api/src/domain/protests/ProtestsService.test.ts - - [ ] Update tests to reflect the reduced responsibility of the protests service and its reliance on presenters for response model creation. - -### Module and providers - -- File: apps/api/src/domain/protests/ProtestsModule.ts - - [ ] Ensure protests presenters are registered as providers and bound as output port implementations for protests use cases. - -- File: apps/api/src/domain/protests/ProtestsProviders.ts - - [ ] Review token definitions and usage for protests repositories and use cases and ensure consistent usage for injection. - - [ ] Add or adjust tokens for output port wiring. - -### Presenters - -- (Any protests presenters to be added or identified during implementation) - - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. - ---- - -## Race domain module - -Directory: apps/api/src/domain/race - -### Controllers - -- File: apps/api/src/domain/race/RaceController.ts - - [x] Ensure controller methods call race service methods that return response models and do not perform any mapping from race domain entities. - - [x] Adjust controller return types to reflect race response models created by presenters. - -- File: apps/api/src/domain/race/RaceController.test.ts - - [x] Update tests so they verify controller behavior in terms of response models and error handling based on use case Results. - -### Services - -- File: apps/api/src/domain/race/RaceService.ts - - [x] Identify all mapping logic from race domain entities to DTOs and move it into race presenters. - - [x] Ensure each service method calls the relevant race use case, lets it present through race presenters, and returns response models from presenters. - - [x] Confirm race repositories and use cases are injected via tokens only and that no presenter is injected via dependency injection. - -- File: apps/api/src/domain/race/RaceService.test.ts - - [x] Update tests to reflect that the race service now delegates mapping to presenters and returns response models. - -### Module and providers - -- File: apps/api/src/domain/race/RaceModule.ts - - [x] Ensure race presenters are registered as providers and bound as output port implementations for race use cases. - -- File: apps/api/src/domain/race/RaceModule.test.ts - - [x] Update tests to confirm correct wiring of race presenters and token-based use case injection. - -- File: apps/api/src/domain/race/RaceProviders.ts - - [x] Verify token definitions for race repositories and use cases and ensure consistent usage. - - [x] Add or adjust tokens to support output port wiring. - -### DTOs - -- Files: apps/api/src/domain/race/dtos/AllRacesPageDTO.ts, DashboardDriverSummaryDTO.ts, DashboardFeedSummaryDTO.ts, DashboardFriendSummaryDTO.ts, DashboardLeagueStandingSummaryDTO.ts, DashboardOverviewDTO.ts, DashboardRaceSummaryDTO.ts, DashboardRecentResultDTO.ts, FileProtestCommandDTO.ts, GetRaceDetailParamsDTO.ts, ImportRaceResultsDTO.ts, ImportRaceResultsSummaryDTO.ts, QuickPenaltyCommandDTO.ts, RaceActionParamsDTO.ts, RaceDetailDTO.ts, RaceDetailEntryDTO.ts, RaceDetailLeagueDTO.ts, RaceDetailRaceDTO.ts, RaceDetailRegistrationDTO.ts, RaceDetailUserResultDTO.ts, RacePenaltiesDTO.ts, RacePenaltyDTO.ts, RaceProtestDTO.ts, RaceProtestsDTO.ts, RaceResultDTO.ts, RaceResultsDetailDTO.ts, RacesPageDataDTO.ts, RacesPageDataRaceDTO.ts, RaceStatsDTO.ts, RaceWithSOFDTO.ts, RegisterForRaceParamsDTO.ts, RequestProtestDefenseCommandDTO.ts, ReviewProtestCommandDTO.ts, WithdrawFromRaceParamsDTO.ts - - [x] Ensure these DTOs serve exclusively as API-level input and response models and are not used directly by core race use cases. - - [x] Align naming and structures with race response models produced by presenters. - -### Presenters - -- Files: apps/api/src/domain/race/presenters/AllRacesPageDataPresenter.ts, GetAllRacesPresenter.ts, GetTotalRacesPresenter.ts, ImportRaceResultsApiPresenter.ts, RaceDetailPresenter.ts, RacePenaltiesPresenter.ts, RaceProtestsPresenter.ts, RaceWithSOFPresenter.ts - - [x] Ensure each race presenter implements the use case output port contract for its race result model. - - [x] Ensure each presenter maintains internal response model state derived from core race result models and exposes getters. - - [x] Move all race mapping logic and DTO construction from the race service into these presenters. - - [x] Use response model terminology consistently within presenters. - -- File: apps/api/src/domain/race/presenters/GetAllRacesPresenter.test.ts - - [x] Update tests so they validate presenter-based mapping from core race result models to race response models and reflect the absence of mapping logic in the service. - ---- - -## Sponsor domain module - -Directory: apps/api/src/domain/sponsor - -### Controllers - -- File: apps/api/src/domain/sponsor/SponsorController.ts - - [ ] Ensure controller methods depend on sponsor service methods that return sponsor response models and avoid direct mapping. - - [ ] Align controller return types with sponsor response models constructed by presenters. - -### Services - -- File: apps/api/src/domain/sponsor/SponsorService.ts - - [ ] Identify and move all sponsor mapping and DTO construction from the service into sponsor presenters. - - [ ] Ensure service methods call sponsor use cases, allow presenters to handle output via output ports, and return response models from presenters. - - [ ] Confirm sponsor repositories and use cases are injected via tokens only, with no presenter injection. - -- File: apps/api/src/domain/sponsor/SponsorService.test.ts - - [ ] Update tests to reflect that sponsor services return response models created by presenters and no longer perform mapping. - -### Module and providers - -- File: apps/api/src/domain/sponsor/SponsorModule.ts - - [ ] Ensure sponsor presenters are registered as providers and bound as output port implementations for sponsor use cases. - -- File: apps/api/src/domain/sponsor/SponsorProviders.ts - - [ ] Review token definitions and usage for sponsor repositories and use cases and adjust as needed for output port wiring. - -### Presenters - -- (Any sponsor presenters present or added during implementation) - - [ ] Ensure they implement the use case output port contract, maintain internal response model state, and expose getters for response models. - ---- - -## Team domain module - -Directory: apps/api/src/domain/team - -### DTOs - -- Files: apps/api/src/domain/team/dtos/GetTeamDetailsOutputDTO.ts, UpdateTeamOutputDTO.ts - - [ ] Ensure these DTOs are used exclusively as API response models and are not referenced directly by core team use cases. - - [ ] Align naming and fields with response models created by team presenters. - -### Presenters - -- Files: apps/api/src/domain/team/presenters/AllTeamsPresenter.ts, CreateTeamPresenter.ts, DriverTeamPresenter.ts, TeamDetailsPresenter.ts - - [ ] Ensure each team presenter implements the use case output port contract for its corresponding team result model. - - [ ] Ensure each presenter maintains internal response model state created from core team result models and exposes response model getters. - - [ ] Move any team mapping or DTO-construction logic from any related services or controllers into these presenters. - -### Services and controllers (if located elsewhere) - -- (Any services or controllers that use the team presenters found in other modules) - - [ ] Ensure these services and controllers treat team presenters as output ports, do not inject presenters directly, and return only response models from presenters. - ---- - -## Final global verification - -- [ ] Run project-wide type checking and resolve any remaining type errors related to use case output ports, presenters, and response models. -- [ ] Run project-wide linting and fix all issues related to unused imports, incorrect injection tokens, or outdated DTO usage. -- [ ] Run the full test suite and ensure that all module tests pass after the migration. -- [ ] Perform a final review of the API layer to confirm that all mapping from domain objects to API representations is performed by presenters, that services are orchestration-only, and that use cases present via output ports and return Result types without performing serialization. \ No newline at end of file diff --git a/plans/auth-clean-arch.md b/plans/auth-clean-arch.md deleted file mode 100644 index 6f6c74bb8..000000000 --- a/plans/auth-clean-arch.md +++ /dev/null @@ -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`, `save(user: User): Promise`. -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
apps/website/lib/auth] --> AuthService[AuthService
apps/website/lib/auth] - AuthService --> LoginUC[LoginUseCase
core/identity/application] - AuthService --> SignupUC[SignupUseCase] - LoginUC --> IAuthRepo[IAuthRepository
core/identity/domain] - SignupUC --> PasswordSvc[PasswordHashingService
core/identity/domain] - IAuthRepo --> InMemRepo[InMemoryUserRepository
adapters/identity/inmem] - AuthService -.-> DI[DI Container
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 { - 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; - save(user: User): Promise; -} -``` - -### 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 { - 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(null!); - -export function AuthProvider({ children }) { - const authService = diContainer.resolve(AuthService); - return {children}; -} - -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. diff --git a/plans/auth-finalization-plan.md b/plans/auth-finalization-plan.md deleted file mode 100644 index af66d67cb..000000000 --- a/plans/auth-finalization-plan.md +++ /dev/null @@ -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> { - // 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> { - // 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> { - // 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 { - 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 { - // 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

(Component: React.ComponentType

) { - 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 ; - } - - if (!session) { - return null; // or redirecting indicator - } - - return ; - }; -} - -// Hook for protected data fetching -export function useProtectedData(fetcher: () => Promise) { - const { session, loading } = useAuth(); - const [data, setData] = useState(null); - const [error, setError] = useState(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 ; - 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 \ No newline at end of file diff --git a/plans/auth-finalization-summary.md b/plans/auth-finalization-summary.md deleted file mode 100644 index 292d85dac..000000000 --- a/plans/auth-finalization-summary.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plans/league-admin-mvp-plan.md b/plans/league-admin-mvp-plan.md deleted file mode 100644 index bfeec9b63..000000000 --- a/plans/league-admin-mvp-plan.md +++ /dev/null @@ -1,169 +0,0 @@ -# League Admin MVP Plan (Admin Acquisition) - -## Goal -Finish all league-management tools needed to attract and retain league admins by making three workflows fully functional, permission-correct, and data-backed: -- Schedule builder + publishing -- Roster + join requests + roles -- Results import + standings recompute - -This plan prioritizes fixing auth/permissions and API contracts first, because they block all three workflows. - ---- - -## What we observed (current state) -### Website gaps (data + correctness) -- Schedule UI assumes a rich race object but contract is effectively `unknown[]` and uses `Date` methods; this is unsafe and likely to break on real API responses. -- Standings page uses real standings + memberships, but fills missing stats fields with placeholders (avgFinish, penaltyPoints, bonusPoints, racesStarted). -- League settings page checks admin via a membership cache, which can falsely deny access if cache isn’t hydrated. -- Stewarding data fetch is N+1: for each race, fetch protests + penalties, which won’t scale. -- Wallet page is explicitly demo/prototype: hardcoded season/account and static breakdown data; also not admin-gated. - -### API gaps (permissions + contract + admin tooling) -- Actor identity is inconsistent: - - Some operations take a performer/admin ID from request data. - - Some operations hardcode an admin ID. - - Some areas infer identity from session. -- Swagger/OpenAPI generation exists in code, but the committed OpenAPI artifact is stale/empty, so it cannot serve as a reliable contract source right now. -- League schedule endpoint exists, but it does not appear to deliver a typed, UI-ready schedule contract that the website expects. -- League admin tooling endpoints exist in fragments (join requests, memberships, config, wallet, protests), but are missing end-to-end admin workflows (schedule editing/publishing, results import flows, etc.) and consistent authorization. - ---- - -## Definition of Done (MVP-wide) -1. Every admin action is authorized server-side based on the authenticated session identity (no client-supplied performer IDs; no hardcoded admin IDs). -2. Website uses stable, generated types for API DTOs; no `unknown[]` schedule data. -3. Admin can: - - Create and publish a season schedule (add/edit/remove races). - - Manage roster and join requests (approve/reject, roles, remove members; enforce capacity). - - Import results and see standings update per season. -4. Performance guardrails: - - No N+1 requests for league stewarding over races; provide aggregate endpoint(s). -5. Quality gates pass in implementation phase: lint, typecheck, tests. - ---- - -## Gap matrix (workflow → missing pieces) -### 1) Auth/Permissions (cross-cutting) -Missing / must improve: -- A single canonical “actor” model (session userId vs driverId mapping). -- Consistent admin/owner authorization checks for all league write operations. -- Removal of performer IDs from all public contracts. - -Dependencies: -- Session endpoint exists; need to decide how driver identity is represented in session and how it’s resolved. - -Deliverable: -- A short doc describing actor model and permission rules, then code changes that enforce them. - -### 2) Schedule builder + publishing -Missing / must improve: -- Contract: schedule DTO must be typed and use ISO strings for dates; website parses to Date in view models. -- Admin endpoints: create/update/delete schedule races, associate to season, publish/unpublish. -- UI: schedule admin interface for managing races. -- Driver registration status: schedule should reflect registration per driver without relying on ad-hoc “isRegistered” fields. - -Deliverable: -- Season-aware schedule read endpoint + schedule write endpoints + website schedule editor. - -### 3) Roster + join requests + roles -Missing / must improve: -- Join requests list exists, but approval/rejection must be permission-correct and actor-derived. -- Role changes and member removal must be actor-derived. -- UI: admin roster page (requests inbox + members list + role controls + remove). -- Capacity/status enforcement at the API layer. - -Deliverable: -- A single roster admin experience that matches API rules. - -### 4) Results import + standings recompute -Missing / must improve: -- Results import UX in website (admin flow) and stable API contract(s) for import + recompute. -- Standings should be season-aware and include fields the UI currently fakes or omits. -- Ensure penalties/protests can affect standings where applicable. - -Deliverable: -- Admin results import page + standings page backed by season-aware API. - -### 5) Stewarding -Missing / must improve: -- Aggregate league stewarding endpoint (races + protests + penalties) to avoid N+1 behavior. -- Confirm admin-only access and correct actor inference for review/apply penalty. - -Deliverable: -- Single endpoint powering stewarding page, plus minimal UI updates. - -### 6) Wallet (scope decision required) -Recommendation: -- Keep wallet “demo-only” for MVP, but make it permission-correct and remove hardcoded season/account IDs. -- Replace static breakdown sections with values derived from the wallet endpoint, or hide them behind a “coming soon” section. - -Deliverable: -- Admin-only wallet access + remove hardcoded values + clearly labeled non-MVP parts. - ---- - -## Proposed execution plan (implementation-ready) -### Phase 0 — Contract & identity foundation (must be first) -- Define the actor model: - - What the session contains (userId, driverId, roles). - - How userId maps to driverId (1:1 or indirect). - - What “league admin” means and where it’s validated. -- Update all league write endpoints to infer actor from session and enforce permissions. -- Remove any hardcoded actor IDs in services. -- Make OpenAPI generation reliable and used as contract source. - -Acceptance criteria: -- No API route accepts performer/admin IDs for authorization. -- OpenAPI doc contains real paths and is regeneratable in CI/local. - -### Phase 1 — Normalize DTOs + website type safety -- Fix website type generation flow so generated DTOs exist and match API. -- Fix schedule DTO contract: - - Race schedule entries use ISO strings. - - Website parses and derives isPast/isUpcoming deterministically. - - Registration state is returned explicitly or derived via a separate endpoint. - -Acceptance criteria: -- No schedule-related `unknown` casts remain. -- Schedule page renders with real API data and correct date handling. - -### Phase 2 — Admin schedule management -- Implement schedule CRUD endpoints for admins (season-scoped). -- Build schedule editor UI (create/edit/delete/publish). -- Ensure driver registration still works. - -Acceptance criteria: -- Admin can publish a schedule and members see it immediately. - -### Phase 3 — Roster and join requests -- Ensure join request approve/reject is actor-derived and permission-checked. -- Provide endpoints for roster listing, role changes, and member removal. -- Build roster admin UI. - -Acceptance criteria: -- Admin can manage roster end-to-end without passing performer IDs. - -### Phase 4 — Results import and standings -- Implement results import endpoints and recompute trigger behavior (manual + after import). -- Make standings season-aware and include additional fields needed by the UI (or simplify UI to match real data). -- Build results import UI and update standings UI accordingly. - -Acceptance criteria: -- Import updates standings deterministically and is visible in UI. - -### Phase 5 — Stewarding performance + wallet cleanup -- Replace N+1 stewarding fetch with aggregate endpoint; update UI to use it. -- Wallet: admin-only gating; remove hardcoded season/account; hide or compute static sections. - -Acceptance criteria: -- Stewarding loads in bounded requests. -- Wallet page does not contain hardcoded season/account IDs. - -### Phase 6 — Quality gates -- Run lint, typecheck, and tests until clean for all changes introduced. - ---- - -## Key decisions / assumptions (explicit) -- “Admin acquisition” MVP includes all three workflows and therefore requires solid permissions and contracts first. -- Wallet is not a blocker for admin acquisition but must not mislead; keep demo-only unless you want it fully MVP. diff --git a/plans/media-avatars-team-league-logos-streamlining-plan.md b/plans/media-avatars-team-league-logos-streamlining-plan.md deleted file mode 100644 index fbf1ee9a8..000000000 --- a/plans/media-avatars-team-league-logos-streamlining-plan.md +++ /dev/null @@ -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. - diff --git a/plans/media-seeding-plan.md b/plans/media-seeding-plan.md deleted file mode 100644 index b9b88a522..000000000 --- a/plans/media-seeding-plan.md +++ /dev/null @@ -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 { - 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; -getTeamLogo(teamId: string): Promise; -``` - -### 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. \ No newline at end of file diff --git a/plans/media-streamlining-debug-fix-plan.md b/plans/media-streamlining-debug-fix-plan.md deleted file mode 100644 index 8948309b4..000000000 --- a/plans/media-streamlining-debug-fix-plan.md +++ /dev/null @@ -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 [``](apps/website/components/teams/TeamCard.tsx:101) - -- Some UI code uses an internal URL builder that does not match the API’s route shapes: - - [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) builds `/media/generated/team-logo/:id` etc. - - Example usage: [`TeamLadderRow`](apps/website/components/teams/TeamLadderRow.tsx:18) uses [`getMediaUrl('team-logo', teamId)`](apps/website/components/teams/TeamLadderRow.tsx:29) - -- Next.js image config currently allows localhost and allows SVG: - - [`next.config.mjs`](apps/website/next.config.mjs:1) includes `remotePatterns` for `localhost:3001` and `dangerouslyAllowSVG: true`. - -## 2) Suspected root causes (ranked) - -### A. URL shape mismatch in Website fallback builder - -The Website builder [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) generates paths like: - -- `/media/generated/team-logo/:id` - -But the API serves: - -- `/media/teams/:id/logo` or `/media/generated/team/:id` (generic endpoint) - -Result: 404s for any page that uses [`getMediaUrl()`](apps/website/lib/utilities/media.ts:11) instead of `logoUrl` returned by the API. - -### B. Runtime accidentally uses the in-memory resolver (misleading) - -In API Team DI, the runtime media resolver is currently the stub [`InMemoryMediaResolverAdapter`](adapters/media/MediaResolverInMemoryAdapter.ts:60) via [`TeamProviders`](apps/api/src/domain/team/TeamProviders.ts:28). - -That adapter is explicitly described as “fake URLs” and has URL shapes that don’t match the API controller, e.g. system-default returns `${base}/default/${ref.variant}` in [`InMemoryMediaResolverAdapter.resolve()`](adapters/media/MediaResolverInMemoryAdapter.ts:80). - -Even if team logos are “generated” and map to `/media/teams/:id/logo`, this is an architectural footgun: -- It makes it easy for other entity presenters (drivers/leagues/etc.) to emit non-existent URLs. -- It undermines confidence when debugging. - -### C. Next.js `Image` error symptoms - -You reported: Next.js `Image` errors about remote host not configured and or SVG blocked. - -Given [`next.config.mjs`](apps/website/next.config.mjs:12) appears to allow `localhost:3001` and enables SVG, this suggests at least one of: -- The actual `src` host differs (e.g. `127.0.0.1`, `api:3000`, or another hostname). -- The `src` is not a valid URL string at runtime (empty string, malformed). -- A stale container is running with older config. - -The plan below makes `src` always same-origin to the Website (relative `/media/...`), eliminating this entire class of errors. - -## 3) Target architecture (strict, minimal, easy-to-reason) - -### 3.1 Invariants (rules) - -1) Canonical media URLs are always *paths* starting with `/media/`. -2) API DTO fields like `team.logoUrl` are either: - - `null`, or - - a path `/media/...` (never absolute URLs, never empty string). -3) The Website renders media using *only*: - - DTO-provided `/media/...` URLs, or - - a single shared Website builder that produces `/media/...` URLs matching the API routes. -4) The Website never needs to know `http://localhost:3001`. -5) All runtime resolution uses exactly one resolver implementation (no stubs). - -### 3.2 One canonical path schema - -Canonical HTTP paths (served by API, fetched by browser via Website proxy rewrite): - -- Team logo SVG: `/media/teams/{teamId}/logo` -- League logo SVG: `/media/leagues/{leagueId}/logo` -- Driver avatar SVG: `/media/avatar/{driverId}` -- Defaults (PNG): `/media/default/{variant}` -- Uploaded: `/media/uploaded/{mediaId}` - -`/media/generated/:type/:id` can remain, but should become an internal alias only (not returned by resolvers/presenters). - -### 3.3 Single resolver for the whole API - -- Runtime resolver: [`MediaResolverAdapter`](adapters/media/MediaResolverAdapter.ts:53) using the concrete sub-resolvers: - - [`DefaultMediaResolverAdapter`](adapters/media/resolvers/DefaultMediaResolverAdapter.ts:34) - - [`GeneratedMediaResolverAdapter`](adapters/media/resolvers/GeneratedMediaResolverAdapter.ts:35) - - [`UploadedMediaResolverAdapter`](adapters/media/resolvers/UploadedMediaResolverAdapter.ts:37) - -Resolver output must be *path-only*: -- For any `MediaReference`, `resolve()` returns `/media/...` or `null`. -- No `baseUrl` parameter is needed for DTOs. - -Rationale: once URLs are path-only, the Website can proxy them and Next `Image` becomes deterministic. - -### 3.4 Proper storage abstraction (core port) + adapter implementation - -This is required to align with Clean Architecture rules in [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1) and avoid runtime stubs. - -#### 3.4.1 Core (ports + use-cases) - -We already have a core port [`MediaStoragePort`](apps/api/src/domain/media/MediaProviders.ts:9) used by the media use-cases (upload/delete). The plan is to make it real and remove mock usage in runtime. - -Target responsibilities: - -- Core Application port (interface): `MediaStoragePort` - - `uploadMedia(file, metadata) -> { success, url?, filename?, storageKey?, contentType? }` - - `deleteMedia(storageKey) -> void` - - (optional but recommended) `getReadStream(storageKey) -> stream` or `getBytes(storageKey) -> Buffer` - -- Core Domain entity (or value object): `Media` should reference a storage identifier (e.g. `storageKey`) and `contentType`. - - The domain does not store absolute URLs. - - The resolver + controller decide how a `storageKey` becomes `/media/uploaded/{id}`. - -#### 3.4.2 Adapters (file storage) - -Add a concrete adapter: `FileSystemMediaStorageAdapter` under `adapters/`. - -Implementation rules: - -- Store files under a single base directory (configured via env): - - `GRIDPILOT_MEDIA_STORAGE_DIR=/data/media` (container path) -- Use deterministic, collision-resistant keys: - - `uploaded/{mediaId}/{originalFilename}` or `uploaded/{mediaId}` (single-file per mediaId) -- Enforce content-type allowlist for images (at minimum `image/png`, `image/jpeg`, `image/svg+xml`). -- Never return public absolute URLs from the adapter. Return `storageKey` only. - -Docker alignment: - -- Add a named volume mounted into `api` container for persisted dev media. - -#### 3.4.3 API serving route for uploaded media - -The API endpoint [`GET /media/uploaded/:mediaId`](apps/api/src/domain/media/MediaController.ts:169) is currently a stub. - -Target: - -- Look up `Media` by `mediaId` in `IMediaRepository`. -- Read bytes/stream from `MediaStoragePort` using `storageKey`. -- Set headers: - - `Content-Type: ` - - `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-` → `/media/teams//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 ``, 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. diff --git a/plans/nextjs-rsc-viewmodels-concept.md b/plans/nextjs-rsc-viewmodels-concept.md deleted file mode 100644 index a53ee7d20..000000000 --- a/plans/nextjs-rsc-viewmodels-concept.md +++ /dev/null @@ -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/PageQuery.ts` - -The query class MUST: - -- call services that call `apps/api` (example current service: [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:10)) -- return a Page DTO (defined below) -- contain no formatting/filtering/sorting - -The query class MUST NOT: - -- contain business rules -- contain canonical ordering decisions - -If ordering/filtering is needed, it MUST be implemented in `apps/api`. - -### 2.3 Client ViewModels - -ViewModels live in [apps/website/lib/view-models](apps/website/lib/view-models/DriverProfileViewModel.ts:1). - -ViewModels MUST: - -- be instantiated only in client modules (`'use client'`) -- accept DTOs only (plain data) -- expose view-only derived values (never business truth) - -ViewModels MUST NOT be passed into Templates. - -ViewModels SHOULD be the primary place that *composes* Display Objects. - -### 2.4 Display Objects - -Display Objects follow [docs/architecture/DISPLAY_OBJECTS.md](docs/architecture/DISPLAY_OBJECTS.md:1). - -Display Objects MUST: - -- live under `apps/website/lib/display-objects/*` (example existing: [apps/website/lib/display-objects/LeagueRoleDisplay.ts](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1)) -- be deterministic and side-effect free -- be the ONLY place where formatting/mapping conventions exist - -Pages MUST NOT format. Templates MUST NOT format. - -Display Objects lifecycle (strict): - -- Display Objects are created in client code. -- Display Objects are typically created by ViewModels (recommended), and their primitive outputs are used to build ViewData. -- Display Object instances MUST NOT cross any serialization boundary (RSC boundary, network, storage). - -#### 2.4.1 Display Objects are Frontend Value Objects (strict definition) - -Treat Display Objects like Domain Value Objects, but for the **presentation layer**. - -Display Objects are: - -- **Class-based** -- **Immutable** -- **Small** (one concept per object) -- **Deterministic** (same input -> same output everywhere) -- **Side-effect free** - -Display Objects are NOT: - -- utility modules of exported functions -- global lookup tables exported for ad hoc access -- a place to hide page logic - -Why strict class-based? - -- Naming: an object name communicates the concept (example: `MoneyDisplay`, `CountryDisplay`, `MonthYearDisplay`) -- Encapsulation: invariants and normalization live in one constructor path -- Reuse: ViewModels can share them without duplicating logic - -#### 2.4.2 Allowed responsibilities - -Display Objects MAY: - -- format raw values into **display strings** (date, number, money) -- map codes -> labels/icons/style tokens -- expose variants explicitly (for example `asShortLabel()`, `asLongLabel()`) - -Display Objects MUST NOT: - -- contain business rules (those live in `apps/api`) -- validate domain invariants -- call network or storage -- depend on framework runtime (React, Next.js) -- depend on runtime locale/timezone formatting APIs (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) “Deterministic formatting requirement for Display Objects”) - -#### 2.4.3 Strict API shape - -Each Display Object class MUST: - -- have a single responsibility -- accept only primitives in its constructor (or static constructor) -- expose only primitive outputs (strings/numbers/booleans) - -Recommended pattern: - -- `private constructor(...)` -- `static fromX(value: ...)` factory for normalization -- instance methods like `toString()`, `label()`, `cssClassToken()`, `ariaLabel()` - -Non-negotiable: no exported free functions as the primary API. - -#### 2.4.4 Serialization boundary rule - -Display Objects MUST NEVER appear in: - -- Page DTOs crossing server -> client -- ViewData passed into Templates - -Only the Display Object’s primitive outputs may be copied into ViewData. - -## 3) ViewData for Templates (strict) - -Templates MUST render **ViewData**, not ViewModels. - -Definitions: - -- **Page DTO**: the serializable data returned by a server query and passed across the RSC boundary. -- **ViewModel**: client-only object that encapsulates view-only derivations and composes Display Objects. -- **ViewData**: a JSON-serializable, template-ready data structure that Templates render. - -Rules: - -1) ViewData MUST be JSON-serializable (same restrictions as Page DTO in [Section 3](plans/nextjs-rsc-viewmodels-concept.md:83)). -2) ViewData MUST contain only values ready for display. Templates MUST NOT format. -3) ViewData MUST be produced in client code: - - Initial render: from Page DTO (SSR-safe) - - Post-hydration: from ViewModel (client-only) -4) Formatting implementation MUST live in Display Objects in `apps/website/lib/display-objects/*`. -5) ViewData MUST NOT contain Display Object instances. ViewData contains only primitives (mostly strings) that were produced by Display Objects. - -Rationale: Display Objects are classes/value objects and are not safe to serialize across the Next.js Client Component boundary. They are used as deterministic formatters/mappers, but only their primitive outputs may enter ViewData. - -## 4) DTO boundary (RSC boundary) - -### 4.1 Page DTO definition - -The ONLY data that may cross from a server component into a client component is a Page DTO. - -Page DTOs MUST: - -- be JSON-serializable -- contain only primitives, arrays, and plain objects -- use ISO strings for timestamps -- use `null` for missing values (no `undefined`) - -Page DTOs MUST NOT contain: - -- ViewModels -- Display Objects -- `Date` -- `Map` / `Set` -- functions - -### 4.2 DTO types - -When a ViewModel already defines its raw data type, that raw data type IS the Page DTO. - -Example (profile): [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93). - -Dashboard MUST define an equivalent `DashboardOverviewViewModelData` (or analogous) next to the dashboard ViewModel. - -## 4.3 Deterministic formatting requirement for Display Objects - -Because ViewData is rendered during SSR and re-rendered after hydration, any formatting used to produce ViewData MUST be deterministic across Node and the browser. - -Therefore Display Objects MUST NOT use locale-dependent runtime formatting APIs, including: - -- `Intl.*` -- `Date.toLocaleString()` / `Date.toLocaleDateString()` - -This policy is strict and global for `apps/website`: `Intl.*` and `toLocale*` are forbidden everywhere in rendering codepaths (pages, templates, components, view models, display objects). If formatting is required, it MUST be implemented deterministically via explicit algorithms/lookup tables. - -Display Objects MAY use: - -- explicit lookup tables (example: month names) -- numeric formatting implemented without locale APIs - -This is the only way to guarantee identical SSR and client outputs. - -## 4.4 ViewData and Display Objects (serialization rule) - -Display Objects are classes/value objects. They are NOT guaranteed to be serializable. - -Therefore: - -- ViewData MUST NOT contain Display Object instances. -- ViewData contains only primitives (usually strings) produced by Display Objects. - -## 5) Query result contract (no `null`) - -Rationale: returning `null` from server-side fetch orchestration conflates “not found”, “unauthorized/redirect”, and “unexpected error”. This makes route behavior ambiguous and encourages pages to implement policy via ad hoc checks. - -Therefore, this concept forbids `null` as a query outcome. - -### 5.1 Mandatory `PageQueryResult` discriminated union - -Every server query class (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:42)) MUST return a discriminated union: - -- `ok` with `{ dto: PageDTO }` -- `notFound` -- `redirect` with `{ to: string }` -- `error` with `{ errorId: string }` (and logging done server-side) - -Pages MUST switch on this result and decide: - -- `notFound` -> [`notFound()`](apps/website/app/dashboard/page.tsx:1) -- `redirect` -> [`redirect()`](apps/website/app/leaderboards/page.tsx:7) -- `error` -> throw to Next.js error boundary or render route error boundary - -### 5.2 `PageDataFetcher` usage rule - -The current [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) and [`PageDataFetcher.fetchManual()`](apps/website/lib/page/PageDataFetcher.ts:36) return `null` on error. - -In the new architecture: - -- Server page modules MUST NOT consume `null`-returning APIs for route decisions. -- Server query classes MUST wrap any usage of [`PageDataFetcher`](apps/website/lib/page/PageDataFetcher.ts:9) into `PageQueryResult` and MUST NOT leak `null` upward. - -If `PageDataFetcher` is refactored later, its single-fetch methods MUST return a result type (similar to [`FetchResult`](apps/website/lib/page/PageDataFetcher.ts:3)) rather than `null`. - -## 5.3 DI usage (strict) - -This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1). - -Rules: - -1) `page.tsx` modules MUST NOT access the DI container directly (no [`ContainerManager.getInstance()`](apps/website/lib/di/container.ts:67)). -2) Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests. -3) Because [`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container. -4) Client components MAY use DI via `ContainerProvider` + hooks like [`useInject`](apps/website/lib/di/hooks/useInject.ts:1). - -Non-negotiable: no stateful service instances may be shared across requests via the singleton container. - -## 6) Required component shape per route - -Every route MUST be structured as: - -1) `page.tsx` (Server Component) -2) `*PageClient.tsx` (Client Component) -3) `*Template.tsx` (pure stateless UI) - -### 6.1 Server `page.tsx` - -Server `page.tsx` MUST: - -- call the route query class -- pass only the Page DTO into the client component - -Server `page.tsx` MUST NOT: - -- import from `apps/website/lib/view-models/*` -- instantiate ViewModels - -### 6.2 Client `*PageClient.tsx` - -Client `*PageClient.tsx` MUST: - -- start with `'use client'` -- accept the Page DTO as prop -- render the Template with **ViewData** - -Client `*PageClient.tsx` MUST implement a two-phase render: - -1) Initial render (SSR-safe): - - MUST NOT instantiate ViewModels - - MUST create initial ViewData directly from Page DTO - - MUST render Template with initial ViewData - -2) Post-hydration (client-only): - - MUST instantiate the ViewModel - - MUST derive enhanced ViewData from the ViewModel (using Display Objects) - - MUST re-render Template with enhanced ViewData - -## 6.4 Initial SSR ViewData policy (non-optional) - -Initial SSR ViewData MUST be **fully populated**, but only using deterministic formatting as defined in [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) under “Deterministic formatting requirement for Display Objects”. - -This yields: - -- SSR delivers meaningful content (no skeleton-only pages) -- Hydration stays stable because the same deterministic Display Objects run on both SSR and client - -### 6.3 `*Template.tsx` (pure UI) - -Templates MUST: - -- be pure and stateless -- accept `ViewData` only -- contain no formatting logic -- contain no filtering/sorting logic - -Templates MAY be imported by server or client modules. - -Templates MUST NOT import: - -- `apps/website/lib/view-models/*` -- `apps/website/lib/display-objects/*` - -## 7) Hydration safety (strict) - -Hydration mismatch warnings are treated as build-breaking defects. - -Forbidden in any `page.tsx` module under [apps/website/app](apps/website/app/page.tsx:1): - -- [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430) -- any other locale/timezone dependent formatting -- any non-determinism (`Math.random`, `Date.now`) during render - -All human-readable formatting MUST be done via Display Objects in the client. - -Additionally forbidden anywhere Display Objects are executed to produce ViewData: - -- `Intl.*` -- `Date.toLocaleString()` / `Date.toLocaleDateString()` - -## 8) Guardrails (mandatory) - -### 8.1 Boundary tests - -Extend [apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts](apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts:1) with tests that FAIL when: - -- any `apps/website/app/**/page.tsx` imports from `apps/website/lib/view-models/*` -- any `apps/website/app/**/page.tsx` contains banned formatting calls (including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430)) -- any `apps/website/app/**/page.tsx` contains sorting/filtering logic (`sort`, `filter`, `reduce`) outside trivial null checks - -Add template boundary tests that FAIL when: - -- any `apps/website/templates/**` imports from `apps/website/lib/view-models/*` -- any `apps/website/templates/**` imports from `apps/website/lib/display-objects/*` - -### 8.2 ESLint restrictions - -Add ESLint restrictions that enforce the same rules at authoring time. - -## 9) Migration steps (dashboard first, then profile) - -### 9.1 Dashboard - -Starting point: [apps/website/app/dashboard/page.tsx](apps/website/app/dashboard/page.tsx:1). - -Steps: - -1) Introduce `DashboardPageQuery` under `apps/website/lib/page-queries/*` that returns a Dashboard Page DTO. -2) Change the dashboard server page to call the query and render `DashboardPageClient`. -3) Create `DashboardPageClient` as client module: - - Initial render: builds ViewData from DTO and renders [`DashboardTemplate`](apps/website/templates/DashboardTemplate.tsx:1). - - Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template. -4) Ensure any display formatting is implemented as Display Objects. - -### 9.2 Profile - -Starting point: [apps/website/app/profile/page.tsx](apps/website/app/profile/page.tsx:1). - -Steps: - -1) Move all helper logic out of the page module into a template and Display Objects. -2) Make profile `page.tsx` a server component that calls a query class returning [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93). -3) Create `ProfilePageClient` as client module: - - Initial render: builds ViewData from DTO and renders the template. - - Post-hydration: instantiates [`DriverProfileViewModel`](apps/website/lib/view-models/DriverProfileViewModel.ts:108), builds enhanced ViewData, re-renders template. -4) Remove all formatting in the page module, including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430). - -## 10) Acceptance criteria - -1) No hydration mismatch warnings on dashboard and profile. -2) No ViewModel instantiation in server modules. -3) No formatting/sorting/filtering logic in any module under [apps/website/app](apps/website/app/page.tsx:1). -4) All formatting is encapsulated by Display Objects under `apps/website/lib/display-objects/*`. diff --git a/plans/ratings-architecture-concept.md b/plans/ratings-architecture-concept.md deleted file mode 100644 index ca329cace..000000000 --- a/plans/ratings-architecture-concept.md +++ /dev/null @@ -1,540 +0,0 @@ -# Ratings Architecture Concept (Multi-Rating + Transparency + Eligibility) - -This concept defines a **clean, extendable architecture** for ratings in GridPilot with: -- Our own platform ratings (computed only from GridPilot league activity). -- External per-game ratings (e.g. iRacing iRating/SR) stored separately for **display + eligibility filtering** only. -- A **transparent rating ledger** so users can see exactly why they gained/lost rating. - -It is designed to fit the project’s Clean Architecture + CQRS Light rules in: -- [`ARCHITECTURE.md`](docs/ARCHITECTURE.md:1) -- [`Domain Objects`](docs/architecture/DOMAIN_OBJECTS.md:1) -- [`CQRS Light`](docs/architecture/CQRS.md:1) -- [`Use Cases`](docs/architecture/USECASES.md:1) -- [`View Models`](docs/architecture/VIEW_MODELS.md:1) - -It is also aligned with the principles in: -- [`GridPilot Rating`](docs/concept/RATING.md:1) -- [`Stats`](docs/concept/STATS.md:1) - ---- - -## 1. Requirements Summary - -### 1.1 Must Have (now) -- **Platform ratings** - - `driving`: combines clean + fast driving (and also accounts for AFK/DNS/DNF/DSQ). - - `adminTrust`: administrative trust score. -- **Per-game ratings** - - Stored per game (e.g. iRacing `iRating`, `safetyRating`) for display + eligibility filters. - - Not used to compute platform ratings. -- **Transparency** - - UI must show “why did my rating change” with plus/minus, reason, and reference context. - - A persisted rating ledger is required. - -### 1.2 Future (design for, do not implement now) -- `stewardTrust` -- `broadcasterTrust` - -### 1.3 Non-Functional -- Architecture is **easy to maintain** and **easy to access** (used across many locations). -- Strong separation of concerns: domain is pure; commands enforce invariants; queries are pragmatic. -- Extendability: new rating dimensions and new event types should not cause rewrites. - ---- - -## 2. Key Architectural Decisions - -### 2.1 Platform ratings are computed only from GridPilot events -External game ratings are: -- Stored independently, -- Displayed and queried, -- Usable in eligibility filters, -- Not inputs to platform rating computation. - -### 2.2 Ledger-first transparency -Every rating adjustment is represented as an immutable **rating event** in a ledger, with: -- Who: userId (subject) -- What: dimension (driving/adminTrust/…) -- Why: reason code + human-readable summary + structured metadata -- How much: delta (+/-) and optional weight -- Where: reference to a domain object (raceId, penaltyId, voteId, adminActionId) - -Snapshots are derived from ledger events, not the other way around. - -### 2.3 CQRS Light split -- Commands record rating events and recompute snapshots. -- Queries provide fast read models for UI and eligibility evaluation, without loading domain aggregates. - -### 2.4 Evolution path from existing code -There is already a multi-dimensional value object [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1) and a domain service [`RatingUpdateService`](core/identity/domain/services/RatingUpdateService.ts:1) triggered by [`CompleteRaceUseCaseWithRatings.execute()`](core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts:47). - -This concept treats the existing [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1) as an early “snapshot-like” model and proposes a controlled evolution: -- Keep a snapshot object (can stay named `UserRating` or be renamed later). -- Add a ledger model + repositories + calculators. -- Gradually redirect the write flow from “direct updates” to “record events + recompute snapshot”. - -No “big bang rewrite”. - ---- - -## 3. Domain Model (Core Concepts) - -### 3.1 Bounded contexts -- **Identity context** owns user reputation/ratings (consistent with current placement of [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1)). -- **Racing context** emits race outcomes (finishes, incidents, statuses) and penalties/DSQ information; it does not own rating logic. -- **Admin/Competition context** emits admin actions and vote outcomes; it does not own rating logic. - -### 3.2 Rating dimensions (extendable) -Define a canonical dimension key set (enum-like union) for platform ratings: - -- `driving` -- `adminTrust` -- `stewardTrust` (future) -- `broadcasterTrust` (future) - -Rule: adding a dimension should require: -- A new calculator strategy, and -- New event taxonomy entries, -not structural redesign. - -### 3.3 Domain objects (suggested) -Domain objects below follow the rules in [`Domain Objects`](docs/architecture/DOMAIN_OBJECTS.md:1). - -**Value Objects** -- `RatingDimensionKey` (e.g. `driving`, `adminTrust`) -- `RatingValue` (0..100 or 0..N; pick one standard scale; recommend 0..100 aligned with [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1)) -- `RatingDelta` (signed float/decimal; stored and displayed) -- `RatingEventId` (uuid-like string) -- `RatingReference` (typed reference union: raceId, penaltyId, voteId, adminActionId) -- `ExternalRating` (per-game rating data point, e.g. iRating, safety rating) -- `GameKey` (e.g. `iracing`, future `acc`, etc.) - -**Entities / Aggregate Roots** -- `RatingLedger` (aggregate root for a user’s rating events) - - Identity: `userId` - - Holds a list/stream of `RatingEvent` (not necessarily loaded fully; repository can stream) -- `RatingEvent` (entity inside ledger or separate entity persisted in table) - - Identity: `ratingEventId` - - Immutable once persisted -- `AdminVoteSession` (aggregate root, scoped to league + admin candidate + window) - - Identity: `voteSessionId` - - Controls who can vote, dedup, time window, and closure - - Emits outcome events that convert to rating ledger events -- `ExternalGameRatingProfile` (aggregate root per user) - - Identity: `userId + gameKey` - - Stores latest known per-game ratings + provenance - -**Domain Services** -- `DrivingRatingCalculator` (pure, stateless) -- `AdminTrustRatingCalculator` (pure, stateless) -- `RatingSnapshotCalculator` (applies ordered events to snapshot) -- `RatingEventFactory` (turns domain facts into rating events) -- `EligibilityEvaluator` (pure evaluation over rating snapshots and external ratings, but invoked from application layer for “decisions”) -- Keep services similar in spirit to [`AverageStrengthOfFieldCalculator.calculate()`](core/racing/domain/services/StrengthOfFieldCalculator.ts:29) and constraints typical of value objects like [`StrengthOfField.create()`](core/racing/domain/value-objects/StrengthOfField.ts:22). - -### 3.4 Rating snapshot (current `UserRating`) -A snapshot is what most screens need: -- latest rating value per dimension, -- confidence/sample size/trend, -- lastUpdated. - -This already exists in [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1). Conceptually, the snapshot is derived from events: -- `value`: derived -- `confidence` + `sampleSize`: derived from count/weights and recentness rules -- `trend`: derived from recent deltas - -Snapshots are persisted for fast reads; events are persisted for transparency. - ---- - -## 4. Rating Ledger (Transparency Backbone) - -### 4.1 Rating event structure (conceptual schema) -A `RatingEvent` should contain: - -- `id`: `RatingEventId` -- `userId`: subject of the rating -- `dimension`: `RatingDimensionKey` -- `delta`: `RatingDelta` -- `weight`: numeric (optional; for sample size / confidence) -- `occurredAt`: Date -- `createdAt`: Date -- `source`: - - `sourceType`: `race` | `penalty` | `vote` | `adminAction` | `manualAdjustment` - - `sourceId`: string -- `reason`: - - `code`: stable machine code (for i18n and filtering) - - `summary`: human text (or key + template params) - - `details`: structured JSON (for UI) -- `visibility`: - - `public`: boolean (default true) - - `redactedFields`: list (for sensitive moderation info) -- `version`: schema version for forward compatibility - -### 4.2 Ledger invariants -- Immutable events (append-only); corrections happen via compensating events. -- Deterministic ordering rule (by `occurredAt`, then `createdAt`, then `id`). -- The snapshot is always reproducible from events (within the same calculator version). - -### 4.3 Calculator versioning -To remain maintainable over time: -- Events reference a `calculatorVersion` used when they were generated (optional but recommended). -- Snapshot stores the latest `calculatorVersion`. -- When the algorithm changes, snapshots can be recomputed in background; events remain unchanged. - ---- - -## 5. Platform Rating Definitions - -### 5.1 Driving rating (clean + fast + reliability) -Driving rating is the platform’s main driver identity rating (as described in [`GridPilot Rating`](docs/concept/RATING.md:1)). - -It is derived from ledger events sourced from race facts: -- Finishing position vs field strength (fast driving component) -- Incidents and penalty involvement (clean driving component) -- Attendance and reliability (DNS/DNF/DSQ/AFK) - -#### 5.1.1 Driver status inputs -We must explicitly model: -- AFK -- DNS (did not start) -- DNF (did not finish) -- DSQ (disqualified) - -These should become explicit event types, not hidden inside one “performance score”. - -#### 5.1.2 Driving event taxonomy (initial) -Examples of ledger event reason codes (illustrative; final list is a product decision): - -Performance: -- `DRIVING_FINISH_STRENGTH_GAIN` -- `DRIVING_POSITIONS_GAINED_BONUS` -- `DRIVING_PACE_RELATIVE_GAIN` (optional) - -Clean driving: -- `DRIVING_INCIDENTS_PENALTY` -- `DRIVING_MAJOR_CONTACT_PENALTY` (if severity exists) -- `DRIVING_PENALTY_INVOLVEMENT_PENALTY` - -Reliability: -- `DRIVING_DNS_PENALTY` -- `DRIVING_DNF_PENALTY` -- `DRIVING_DSQ_PENALTY` -- `DRIVING_AFK_PENALTY` -- `DRIVING_SEASON_ATTENDANCE_BONUS` (optional later) - -Each event must reference source facts: -- `raceId` always for race-derived events -- `penaltyId` for steward/admin penalty events -- additional metadata: start position, finish position, incident count, etc. - -#### 5.1.3 Field strength support -Driving performance should consider strength of field similar to the existing value object [`StrengthOfField`](core/racing/domain/value-objects/StrengthOfField.ts:1) and its service pattern in [`StrengthOfFieldCalculator`](core/racing/domain/services/StrengthOfFieldCalculator.ts:1). - -Concept: the driving calculator receives: -- driver finish data -- field rating inputs (which can be platform driving snapshot values or external iRating for SoF only, depending on product choice) - -Given the earlier decision “platform rating does not use external ratings”, we can still compute SoF using: -- platform driving snapshot values (for users with sufficient data), and/or -- a neutral default for new users -without using external ratings as an input to driving rating itself. - -(If SoF must use iRating for accuracy, it still does not violate “independent” as long as SoF is a *race context signal* and not a *direct driver rating input*. This is a design choice to confirm later.) - -### 5.2 Admin trust rating (hybrid system signals + votes) -Admin trust is separate from driving. - -It must include: -- System-derived actions (timeliness, reversals, consistency, completion of tasks) -- Driver votes among participants in a league - -#### 5.2.1 Voting model (anti-abuse, league-scoped) -Votes are generated within a league, but the rating is global. To avoid abuse: -- Only eligible voters: drivers who participated in the league (membership + minimum participation threshold). -- 1 vote per voter per admin per voting window. -- Voting windows are timeboxed (e.g. weekly/monthly/season-end). -- Votes have reduced weight if the voter has low trust (optional later). -- Votes should be explainable: aggregated outcome + distribution; individual votes may be private. - -Votes produce ledger events: -- `ADMIN_VOTE_OUTCOME_POSITIVE` -- `ADMIN_VOTE_OUTCOME_NEGATIVE` -with reference `voteSessionId` and metadata including: -- leagueId -- eligibleVoterCount -- voteCount -- percentPositive - -#### 5.2.2 Admin system-signal taxonomy (initial) -Examples: -- `ADMIN_ACTION_SLA_BONUS` (responded within SLA) -- `ADMIN_ACTION_REVERSAL_PENALTY` (frequent reversals) -- `ADMIN_ACTION_RULE_CLARITY_BONUS` (published rules/changes; if tracked) -- `ADMIN_ACTION_ABUSE_REPORT_PENALTY` (validated abuse reports) - -All of these should be “facts” emitted by admin/competition workflows, not computed in rating domain from raw infra signals. - ---- - -## 6. External Game Ratings (Per-Game Profiles) - -### 6.1 Purpose -External ratings exist to: -- Display on user profiles -- Be used in eligibility filters - -They do not affect platform ratings. - -### 6.2 Data model (conceptual) -`ExternalGameRatingProfile` per `userId + gameKey` stores: -- `gameKey`: e.g. `iracing` -- `ratings`: map of rating type -> numeric value - - e.g. `iracing.iRating`, `iracing.safetyRating` -- `provenance`: - - `source`: `iracing-api` | `manual` | `import` - - `lastSyncedAt` - - `confidence`/`verified` flag (optional) - -### 6.3 Read surfaces -Queries should provide: -- “latest ratings by game” -- “rating history by game” (optional future) -- “last sync status” - ---- - -## 7. Application Layer (Commands and Queries) - -### 7.1 Command side (write model) -Commands are use-cases that: -- validate permissions -- load required domain facts (race outcomes, votes) -- create rating events -- append to ledger -- recompute snapshot(s) -- persist results - -Must follow [`Use Cases`](docs/architecture/USECASES.md:1): output via presenter/output port, no DTO leakage. - -#### 7.1.1 Command use cases (proposed) -Driving: -- `RecordRaceRatingEventsUseCase` - - Input: `raceId` - - Loads race results (positions, incidents, statuses) - - Produces ledger events for driving -- `ApplyPenaltyRatingEventUseCase` - - Input: `penaltyId` - - Produces event(s) affecting driving and/or fairness dimension - -Admin trust: -- `OpenAdminVoteSessionUseCase` -- `CastAdminVoteUseCase` -- `CloseAdminVoteSessionUseCase` - - On close: create ledger event(s) from aggregated vote outcome -- `RecordAdminActionRatingEventUseCase` - - Called by admin workflows to translate system events into rating events - -Snapshots: -- `RecomputeUserRatingSnapshotUseCase` - - Input: `userId` (or batch) - - Replays ledger events through calculator to update snapshot - -External ratings: -- `UpsertExternalGameRatingUseCase` - - Input: userId, gameKey, rating values, provenance - -### 7.2 Query side (read model) -Queries must be pragmatic per [`CQRS Light`](docs/architecture/CQRS.md:1), and should not use domain entities. - -#### 7.2.1 Query use cases (proposed) -User-facing: -- `GetUserRatingsSummaryQuery` - - returns current platform snapshot values + external game ratings + last updated timestamps -- `GetUserRatingLedgerQuery` - - returns paginated ledger events, filterable by dimension, date range, reason code -- `GetUserRatingChangeExplanationQuery` - - returns a “why” view for a time window (e.g. last race), pre-grouped by race/vote/penalty - -League-facing: -- `GetLeagueEligibilityPreviewQuery` - - evaluates candidate eligibility for a league filter and returns explanation (which condition failed) - -Leaderboards: -- `GetTopDrivingRatingsQuery` -- `GetTopAdminTrustQuery` - ---- - -## 8. Eligibility Filters (Leagues) - -### 8.1 Requirements -Leagues can define eligibility filters against: -- Platform `driving` rating (and future dimensions) -- External per-game ratings (e.g. iRating threshold) - -Eligibility decisions should be explainable (audit trail and UI explanation). - -### 8.2 Filter DSL (typed, explainable) -Define a small filter language that supports: - -- target: - - `platform.driving` - - `platform.adminTrust` - - `external.iracing.iRating` - - `external.iracing.safetyRating` - -- operators: - - `>=`, `>`, `<=`, `<`, `between`, `exists` - -- composition: - - `and`, `or` - -Each evaluation returns: -- `eligible: boolean` -- `reasons: []` each with: - - `target` - - `operator` - - `expected` - - `actual` - - `pass/fail` - -This makes it UI-transparent and debuggable. - ---- - -## 9. Website / UI Transparency Contract - -Per [`View Models`](docs/architecture/VIEW_MODELS.md:1), UI should consume view models built from query DTOs. - -### 9.1 “Ratings” surfaces (suggested) -- User profile: - - Platform driving rating + trend + confidence - - Admin trust rating (if relevant) - - External game ratings section (iRating/SR) -- “Why did my rating change?” page: - - Ledger list with grouping by race/vote/penalty - - Each entry: delta, reason, context (race link), and explanation -- League eligibility panel: - - Filter configured + explanation of pass/fail for a given user - - Should be able to show: “iRating 2200 is below required 2500” and/or “driving 61 is above required 55” - ---- - -## 10. Event Flow Examples - -### 10.1 Race completion updates driving rating -Triggered today by [`CompleteRaceUseCaseWithRatings.execute()`](core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts:47) which calls [`RatingUpdateService.updateDriverRatingsAfterRace()`](core/identity/domain/services/RatingUpdateService.ts:21). - -Target flow (conceptually): - -```mermaid -flowchart LR - RaceCompleted[Race completed] - Cmd[RecordRaceRatingEventsUseCase] - Ledger[Append rating events] - Calc[DrivingRatingCalculator] - Snap[Persist snapshot] - Query[GetUserRatingLedgerQuery] - UI[Profile and Why view] - - RaceCompleted --> Cmd - Cmd --> Ledger - Cmd --> Calc - Calc --> Snap - Snap --> Query - Ledger --> Query - Query --> UI -``` - -### 10.2 Admin vote updates admin trust -```mermaid -flowchart LR - Open[OpenAdminVoteSessionUseCase] - Cast[CastAdminVoteUseCase] - Close[CloseAdminVoteSessionUseCase] - Ledger[Append vote outcome event] - Calc[AdminTrustRatingCalculator] - Snap[Persist snapshot] - UI[Admin trust breakdown] - - Open --> Cast - Cast --> Close - Close --> Ledger - Close --> Calc - Calc --> Snap - Snap --> UI - Ledger --> UI -``` - ---- - -## 11. Maintainability Notes - -### 11.1 Keep calculators pure -All rating computations should be pure functions of: -- Events -- Inputs (like race facts) -- Current snapshot (optional) -No repositories, no IO. - -### 11.2 Stable reason codes -Reason codes must be stable to support: -- filtering -- analytics -- translations -- consistent UI explanation - -### 11.3 Explicit extendability -Adding `stewardTrust` later should follow the same template: -- Add event taxonomy -- Add calculator -- Add ledger reasons -- Add snapshot dimension -- Add queries and UI - -No architecture changes. - ---- - -## 12. Fit with existing `UserRating` and `RatingUpdateService` - -### 12.1 Current state -- Snapshot-like model exists as [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1) -- Race completion triggers rating updates via [`RatingUpdateService`](core/identity/domain/services/RatingUpdateService.ts:1) - -### 12.2 Recommended evolution -- Introduce ledger persistence and repositories first. -- Update the write path so [`RatingUpdateService.updateDriverRatingsAfterRace()`](core/identity/domain/services/RatingUpdateService.ts:21) becomes: - - event generation + append + snapshot recalculation - - not direct “set newValue” - -This preserves the public API while improving transparency and extensibility. - ---- - -## 13. Open Decisions (to confirm before implementation) - -1. Strength of Field inputs: - - Should SoF use platform driving snapshots only, or may it use external iRating as a contextual “field difficulty” signal while still keeping platform ratings independent? - -2. Scale: - - Keep 0..100 scale for platform ratings (consistent with [`UserRating`](core/identity/domain/value-objects/UserRating.ts:1))? - -3. Privacy: - - Which admin trust vote details are public (aggregates only) vs private (individual votes)? - -4. Penalty integration: - - Which penalties affect driving vs admin trust, and how do we ensure moderation-sensitive info can be redacted while keeping rating transparency? - ---- - -## 14. Next Step: Implementation Planning Checklist - -Implementation should proceed in small vertical slices: -- Ledger persistence + query read models -- Driving rating events from race completion including DNS/DNF/DSQ/AFK -- Admin vote sessions and rating events -- Eligibility filter DSL + evaluation query - -All aligned with the project’s CQRS Light patterns in [`CQRS Light`](docs/architecture/CQRS.md:1). \ No newline at end of file diff --git a/plans/seeds-clean-arch.md b/plans/seeds-clean-arch.md deleted file mode 100644 index c2c306935..000000000 --- a/plans/seeds-clean-arch.md +++ /dev/null @@ -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 { - // 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(); - - async findByLeagueId(leagueId: string): Promise { - return Array.from(this.standings.values()) - .filter(s => s.leagueId === leagueId) - .sort((a, b) => a.position - b.position); - } - - async save(standing: Standing): Promise { - 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 { - 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(); - - 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 { - 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(); - - async getDriverStats(driverId: string): Promise { - return this.stats.get(driverId) || null; - } - - async saveDriverStats(driverId: string, stats: DriverStats): Promise { - this.stats.set(driverId, stats); - } - - async getAllStats(): Promise> { - 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 { - // ... existing seeding logic ... - - // After seeding results and standings, compute and store stats - await this.computeAndStoreDriverStats(); - await this.computeAndStoreTeamStats(); - } - - private async computeAndStoreDriverStats(): Promise { - 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; - getTeamLogo(teamId: string): Promise; - getTrackImage(trackId: string): Promise; - getCategoryIcon(categoryId: string): Promise; - getSponsorLogo(sponsorId: string): Promise; -} -``` - -### 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 { - 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 \ No newline at end of file diff --git a/plans/seeds-plan.md b/plans/seeds-plan.md deleted file mode 100644 index 2875068d7..000000000 --- a/plans/seeds-plan.md +++ /dev/null @@ -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)." diff --git a/plans/state-management-consolidation-plan.md b/plans/state-management-consolidation-plan.md deleted file mode 100644 index 180b535dc..000000000 --- a/plans/state-management-consolidation-plan.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plans/team-logos-force-reseed-fix-plan.md b/plans/team-logos-force-reseed-fix-plan.md deleted file mode 100644 index cebf72463..000000000 --- a/plans/team-logos-force-reseed-fix-plan.md +++ /dev/null @@ -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`. - diff --git a/plans/type-inventory.md b/plans/type-inventory.md deleted file mode 100644 index 5dc35cdc7..000000000 --- a/plans/type-inventory.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plans/website-architecture-violations.md b/plans/website-architecture-violations.md index 4c6653a7b..4d6aea743 100644 --- a/plans/website-architecture-violations.md +++ b/plans/website-architecture-violations.md @@ -4,6 +4,542 @@ Scope: `apps/website/**` aligned against `docs/architecture/website/**`, with th This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction. +This version also includes a concrete remediation plan with file-by-file actions. + +--- + +## 0) Target architecture (what good looks like) + +Per-route structure required by [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50): + +```text +app//page.tsx server: composition only +lib/page-queries/... server: fetch + assemble Page DTO + return PageQueryResult +app//PageClient.tsx client: ViewData creation + client state +templates/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`](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 `

`. + - 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 ``. + +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 `` 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 ``. | + +### 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 ``. +3. Add guardrail to forbid `fetch` write methods in client code. + --- ## A) DI contract violations (server-side singleton container usage) @@ -81,6 +617,273 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction. --- +## 6) Display Objects, Command Models, Blockers (fourth scan) + +This section answers: + +1) Are Display Objects used as intended? +2) Where should we introduce Display Objects instead of ad-hoc formatting/mapping? +3) Are Command Models used as intended? +4) Where should we introduce Command Models instead of ad-hoc form state/validation? +5) Are Blockers used as intended? +6) Where should we introduce Blockers instead of ad-hoc UX prevention? + +### 6.1 Display Objects: current state vs contract + +Contract excerpts: + +- Display Objects are deterministic UI-only formatting/mapping helpers ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1)). +- Placement rule: `apps/website/lib/display-objects/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:141)). +- Must be class-based, immutable, deterministic ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46)). +- Must NOT call `Intl.*` or `toLocale*` ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63)). +- Templates must not import Display Objects (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)). + +#### 6.1.1 Violations: Display Objects imported from Templates + +| Violation | Evidence | Why it matters | Required remediation | +|---|---|---|---| +| Template imports a Display Object | [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Templates are required to be ViewData-only and must not import display objects. | Move role badge derivation into Presenter/ViewModel and pass `{ text, badgeClasses }` as ViewData. | + +#### 6.1.2 Structural drift: “Display Objects” implemented as plain functions/records + +The following files live under `lib/display-objects/**`, but they are not class-based value objects (they are exported maps/functions): + +- [`DashboardDisplay.ts`](apps/website/lib/display-objects/DashboardDisplay.ts:1) +- [`ProfileDisplay.ts`](apps/website/lib/display-objects/ProfileDisplay.ts:1) +- [`LeagueRoleDisplay.ts`](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1) (partly mitigated via wrapper class, but still map-backed) + +This conflicts with the strict “class-based” rule in [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46). If we keep them as functions, we should either: + +Clean-code direction (no exceptions): + +- Refactor these modules into small, immutable classes with explicit APIs. +- Keep them deterministic and free of locale APIs. + +#### 6.1.3 Where Display Objects should be introduced (replace scattered formatting/mapping) + +The repo currently performs formatting/mapping directly in Templates, route modules, components, and ViewModels. Those are prime candidates for Display Objects. + +High-signal candidates found: + +| Pattern | Evidence | Display Object to introduce | What the Display Object should output | +|---|---|---|---| +| Date formatting (`toLocaleDateString`, `toLocaleString`) | Many templates, e.g. [`RaceDetailTemplate.tsx`](apps/website/templates/RaceDetailTemplate.tsx:172), and route code e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) | `DeterministicDateDisplay` (class) | `{ shortDate, longDate, shortTime, dateTime }` as strings based on ISO inputs, deterministic timezone policy. +| Number formatting (`toLocaleString`) | Templates like [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:206) and components like [`MetricCard.tsx`](apps/website/components/sponsors/MetricCard.tsx:49) | `DeterministicNumberDisplay` (class) | `{ compact, integer, fixed2 }` or explicit formatting helpers; avoid runtime locale. +| Currency formatting | ViewModels like [`BillingViewModel.ts`](apps/website/lib/view-models/BillingViewModel.ts:85) | `MoneyDisplay` (class) | `formatted` string(s) given `{ currencyCode, minorUnits }` or `{ currency, amount }`. +| Role badge mapping duplicated outside display objects | Switch in [`MembershipStatus.tsx`](apps/website/components/leagues/MembershipStatus.tsx:33) and also display object usage elsewhere ([`StandingsTable.tsx`](apps/website/components/leagues/StandingsTable.tsx:9)) | Consolidate as `LeagueRoleBadgeDisplay` | `{ text, badgeClasses }` (or `{ text, bg, border, textColor }`), deterministic. +| Sponsorship status + time remaining UI mapping | Derived UI flags in [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:138) | `SponsorshipStatusDisplay` | `{ statusText, statusClasses, isExpiringSoon, daysRemainingLabel }` (pure). + +**Important boundary rule:** Display Objects must be used by Presenters/ViewModels, not by Templates. Templates should only see the primitive outputs. + +### 6.2 Command Models: current state vs intended use + +Contract excerpts: + +- Command Models are for transient form state; UX validation only; never derived from ViewModels; never reused from read models ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:61)). +- Canonical placement: `apps/website/lib/command-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:219)). + +#### 6.2.1 Good usage already present + +- `League wizard` uses a command model: [`LeagueWizardCommandModel`](apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts:79) (consumed in [`CreateLeagueWizard.tsx`](apps/website/components/leagues/CreateLeagueWizard.tsx:27)). +- `Auth` command models exist: [`LoginCommandModel`](apps/website/lib/command-models/auth/LoginCommandModel.ts:17) and [`SignupCommandModel`](apps/website/lib/command-models/auth/SignupCommandModel.ts:21). +- `Protest` write intent uses a command model: [`ProtestDecisionCommandModel`](apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts:10) (constructed in [`protests/[protestId]/page.tsx`](apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx:171)). + +#### 6.2.2 Violations / drift: Command-model-like logic living in components + +Example: onboarding wizard duplicates field validation inline: + +- Validation logic in [`OnboardingWizard.tsx`](apps/website/components/onboarding/OnboardingWizard.tsx:202) + +Plan action: + +1. Create `apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts`. +2. Move field-level validation + errors to the command model. +3. Keep the component responsible only for UI state (step navigation, show/hide) and invoking the server action. + +Similarly, any other form pages that implement repeated validation strings/constraints should be moved into command models. + +#### 6.2.3 Server Actions integration: command models should be consumed at the boundary + +Contract requires writes to enter via Server Actions. + +Plan action (repeatable pattern): + +1. UI collects primitives → CommandModel instance (client-only) validates. +2. Submit creates **Command DTO** (plain object) and calls a Server Action. +3. Server Action performs UX validation (not business rules), calls API, redirects/revalidates. + +### 6.3 Blockers: current state vs intended use + +Contract excerpts: + +- Blockers are UX-only, reversible helpers ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7)). +- Client state is UI-only; blockers are not security ([`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:45)). +- Canonical placement: `apps/website/lib/blockers/**` ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:48)). + +#### 6.3.1 Violations: Blockers embedded in services (state leakage risk) + +- [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores blockers as instance fields: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97). +- [`LeagueWalletService`](apps/website/lib/services/leagues/LeagueWalletService.ts:12) does the same: [`submitBlocker`](apps/website/lib/services/leagues/LeagueWalletService.ts:13) and [`throttle`](apps/website/lib/services/leagues/LeagueWalletService.ts:14). + +Why this matters: + +- If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state. + +Plan action: + +1. Remove blockers from service instance state. +2. Reintroduce blockers at the client boundary: + - component local state + - a `useSubmitBlocker()` hook under `apps/website/lib/hooks/**` + - or a per-interaction blocker instance created inside a client function. + +#### 6.3.2 Where blockers should be used instead of ad-hoc UX prevention + +Candidates: + +| UX need | Evidence | Recommended blocker | +|---|---|---| +| Prevent multiple logout clicks | Logout actions exist and currently do client POSTs: [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | `SubmitBlocker` at the UI boundary (button/form) | +| Throttle rapid filter/search updates | Multiple pages render lists with client filtering/sorting, e.g. [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:72) | `ThrottleBlocker` inside a client container or hook (not in service) | + +--- + +## 7) Hooks folder split (`apps/website/hooks` vs `apps/website/lib/hooks`) + +Contract: + +- React-only utilities MUST live under `apps/website/lib/hooks/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217), [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32)). + +Observation: + +- Repo contains **two** hooks locations: + - Top-level: [`apps/website/hooks/useCapability.ts`](apps/website/hooks/useCapability.ts:1) (and many more) + - Canonical: [`apps/website/lib/hooks/useEnhancedForm.ts`](apps/website/lib/hooks/useEnhancedForm.ts:1) (and others) + +Violation: + +| Violation | Evidence | Why it matters | Required remediation | +|---|---|---|---| +| Hooks implemented outside canonical `lib/hooks` | Many hooks under [`apps/website/hooks/**`](apps/website/hooks/useEffectiveDriverId.ts:1) despite contract naming `apps/website/lib/hooks/**` | Confuses imports/boundaries; increases chance hooks depend on route-level concerns or mix server/client incorrectly. Also violates the strict file-structure contract. | Move `apps/website/hooks/**` into `apps/website/lib/hooks/**`, update all imports, remove the top-level folder, and add a guardrail to prevent regression. | + +Notes: + +- The current `tsconfig` explicitly includes top-level `hooks/` ([`tsconfig.json`](apps/website/tsconfig.json:70)), which suggests this drift is intentional-but-undocumented. + +--- + +## 8) `AdminViewModelService` violates ViewModel instantiation + Presenter placement rules + +Contract: + +- ViewModels are client-only classes; they are instantiated only in `'use client'` modules ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:76)). +- Presenters (pure mappings) are colocated with ViewModels in `apps/website/lib/view-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:124)). +- Services are for orchestration, not mapping API DTOs into ViewModels (implied by data-flow contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:145)). + +Evidence: + +- [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:5) is a `lib/services/**` module that performs mapping of API DTOs to ViewModels: + - imports API DTO types from [`AdminApiClient`](apps/website/lib/services/AdminViewModelService.ts:1) + - instantiates `AdminUserViewModel` et al. ([`AdminViewModelService.mapUser()`](apps/website/lib/services/AdminViewModelService.ts:14)) + +Violations: + +| Violation | Evidence | Why it matters | Required remediation | +|---|---|---|---| +| ViewModels instantiated in a non-client module | [`new AdminUserViewModel(dto)`](apps/website/lib/services/AdminViewModelService.ts:15) in a service file with no `'use client'` boundary | If imported by server modules (Page Queries, RSC pages), it breaks the rule that ViewModels are client-only, and risks serializing or executing client-only assumptions on the server. | Move mapping into a Presenter colocated with the ViewModel (e.g. [`AdminUserViewModelPresenter`](apps/website/lib/view-models/AdminUserViewModel.ts:1) or a sibling file) and ensure ViewModel creation happens only in client modules. | +| Presenter logic misclassified as a Service | All methods are pure mapping functions ([`AdminViewModelService.mapUsers()`](apps/website/lib/services/AdminViewModelService.ts:21)) | Encourages importing “services” in server paths, causing architecture erosion (services become dumping grounds). | Rename/rehome as Presenter. Services should call API clients + return Page DTOs, not construct ViewModels. | + +--- + +## 9) Alignment plan (approved direction: align code to contract) +## 9) Alignment plan (Clean Architecture / SOLID, no gaps) + +Non-negotiable target: + +- `app/**/page.tsx`: server composition only (no business logic, no formatting, no filtering). +- `lib/page-queries/**`: server composition + IO only; returns Page DTO; **no ViewModels**; **no DI singleton**. +- Server DI policy: **no DI at all** in PageQueries or `page.tsx` (manual wiring only). +- `lib/services/**`: server-safe orchestration only; returns API DTO or Page DTO; **no ViewModels**; **no blockers**. +- `lib/view-models/**` + Presenters: client-only; pure mapping to ViewData. +- `templates/**`: dumb renderer; ViewData only. + +### 9.1 Guardrails first (prevent regression) + +Add CI-failing checks (tests or ESLint rules). These come first to enforce Clean Architecture boundaries while refactors are in flight: + +1. `app/**/page.tsx` MUST NOT import from `apps/website/lib/view-models/**`. +2. `templates/**` MUST NOT import from `apps/website/lib/view-models/**` nor `apps/website/lib/display-objects/**`. +3. `lib/page-queries/**` MUST NOT import from `apps/website/lib/view-models/**`. +4. `lib/services/**` MUST NOT import from `apps/website/lib/view-models/**`. +5. Forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61). +6. Forbid `Intl.*` and `toLocale*` in all presentation paths. +7. Forbid `*Template.tsx` under `apps/website/app/**`. + +Order of implementation (guardrails): + +1. Server DI ban: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61) and forbid use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) from server modules. +2. Server/client boundary: forbid any imports of `apps/website/lib/view-models/**` from `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**`. +3. Template boundary: forbid imports of view-models and display-objects from `apps/website/templates/**`. +4. Service boundary: forbid imports of view-models from `apps/website/lib/services/**`. +5. Determinism: forbid `Intl.*` and `toLocale*` in all presentation paths. +6. Write boundary: forbid client-side `fetch` with write methods in client modules. + +These align cleanly with SRP (single reason to change per layer) and remove ambiguity. + +### 9.2 Structural cleanups + +1. Move all hooks to `apps/website/lib/hooks/**` (see §7 and §1.7). +2. Convert Display Objects to class-based, immutable modules (see §6.1.2). +3. Delete [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) and replace with a Presenter. + +### 9.3 Service boundary refactor (DTO-only) + +1. For each service currently returning ViewModels (examples: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95), [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:12), [`MediaService`](apps/website/lib/services/media/MediaService.ts:15)): + - Change outputs to API DTOs or Page DTOs. + - Move any ViewModel instantiation into client Presenters. + - Remove blockers/state from services. + +2. Fix the confirmed server/ViewModel leak: + - [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) must not call `viewModel.toDTO()`. + - It must call a service returning Page DTO and return that directly. + +### 9.4 Route refactor recipe (repeatable) + +For every route currently doing logic in `page.tsx`: + +1. `page.tsx`: fetches via PageQuery, switches on result, renders a `*PageClient`. +2. PageQuery: calls services + assembles Page DTO only. +3. `*PageClient.tsx`: instantiates ViewModels (client-only) and produces ViewData via Presenters. +4. Template: renders ViewData only. + +Write path (per-route): + +1. `app//actions.ts`: Server Actions for that route only (mutations, UX validation, redirect/revalidate). +2. `*PageClient.tsx` (or a client component) uses `` to invoke the action. +3. Services called by actions remain DTO-only and do not contain UI logic. + +This is the mechanism that keeps pages free from business logic and leaves no seams for drift. + +### 9.5 ViewModels: allowed, but only behind the client boundary + +You chose: keep ViewModels, enforce client-only. + +Non-negotiable rules (to avoid gaps): + +1. No server module imports from `apps/website/lib/view-models/**`. +2. No `apps/website/lib/services/**` imports from `apps/website/lib/view-models/**`. +3. A ViewModel instance is never passed into a Template. +4. A ViewModel instance is never serialized. + +Allowed flows: + +- Page DTO → Presenter → ViewData → Template +- Page DTO → Presenter → ViewModel → Presenter → ViewData → Template + +This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels). + +--- + ## High-signal file sets (pattern-based indicators) ### Templates importing ViewModels and or Display Objects (forbidden) @@ -100,4 +903,3 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction. - API client exists at root `lib/` instead of `lib/api/`: [`apiClient.ts`](apps/website/lib/apiClient.ts:1) - Non-canonical `lib/page/` exists: [`PageDataFetcher.ts`](apps/website/lib/page/PageDataFetcher.ts:1) -