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

View File

@@ -4,6 +4,542 @@ Scope: `apps/website/**` aligned against `docs/architecture/website/**`, with th
This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
This version also includes a concrete remediation plan with file-by-file actions.
---
## 0) Target architecture (what good looks like)
Per-route structure required by [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50):
```text
app/<route>/page.tsx server: composition only
lib/page-queries/<Route>... server: fetch + assemble Page DTO + return PageQueryResult
app/<route>/<Route>PageClient.tsx client: ViewData creation + client state
templates/<Route>Template.tsx client: pure UI, ViewData-only, no computation
lib/view-models/** client-only: ViewModels + Presenters (pure)
lib/display-objects/** deterministic formatting helpers (no locale APIs)
```
Hard boundaries to enforce:
- No server code imports from `lib/view-models/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
- Templates import neither `lib/view-models/**` nor `lib/display-objects/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
- No locale APIs (`Intl.*`, `toLocale*`) in any formatting path (guardrails: [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:13)).
- All writes enter via Server Actions (contract: [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)).
---
## 1) Remediation plan (explicit actions)
This is the **minimum actionable work** to align the codebase. It is ordered so earlier steps unblock later ones.
### 1.1 Block server-side container usage (DI contract)
**Goal:** zero usages of [`ContainerManager.getInstance().getContainer()`](apps/website/lib/di/container.ts:67) in server execution paths.
Actions (final standard):
1. Ban DI from server execution paths entirely.
- `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**` MUST NOT use DI.
- PageQueries MUST do manual wiring (construct API clients + services explicitly).
2. Delete or rewrite any helper that makes server DI easy.
- Replace any server usage of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) with manual wiring.
- Stop calling [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page/PageDataFetcher.ts:21) from server.
3. Enforcement:
- Add a guardrail test/ESLint rule that forbids importing [`ContainerManager`](apps/website/lib/di/container.ts:61) from:
- `apps/website/app/**/page.tsx`
- `apps/website/lib/page-queries/**`
Deliverable for this section:
- A CI-failing rule that prevents future regressions.
- A grep for `getContainer()` in server modules returns zero hits.
### 1.2 Standardize PageQuery contract (one discriminated union)
**Goal:** all PageQueries return the exact contract described in [`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17).
Actions:
1. Define a single `PageQueryResult` type in a shared place under `apps/website/lib/page-queries/**`.
2. Update all PageQueries to return:
- `ok` with `{ dto }`
- `notFound`
- `redirect` with `{ to }`
- `error` with `{ errorId }` (do not return raw Error objects)
Example violation:
- Current local type uses `data`/`destination`: [`PageQueryResult<TData>`](apps/website/lib/page-queries/DashboardPageQuery.ts:11)
Deliverable:
- All pages use `switch(result.status)` and call `notFound()` / `redirect()` only from `page.tsx`.
### 1.3 Fix Template purity (ViewData-only, no imports, no compute)
**Goal:** Templates become “dumb” renderers: no `useMemo`, no filtering/sorting, no ViewModel or DisplayObject imports.
Actions:
1. Remove ViewModel props from Templates.
- Example: replace [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19)
with `drivers: DriverRankingsViewData[]` (a ViewData type).
2. Remove compute from Templates.
- Example: move [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
into:
- a ViewModel (client) OR
- a Presenter (client) OR
- the PageClient container (client) if it is driven by UI state.
3. Remove DisplayObject usage from Templates.
- Example violation: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13)
- Replace with primitive values in ViewData (badge label, badge classes).
Deliverable:
- Guardrail tests (or ESLint) that fail if any `apps/website/templates/**` imports from:
- `apps/website/lib/view-models/**`
- `apps/website/lib/display-objects/**`
### 1.4 Eliminate locale APIs from all formatting paths
**Goal:** zero usage of `Intl.*` and `toLocale*` in:
- `apps/website/templates/**`
- `apps/website/app/**/page.tsx`
- `apps/website/lib/view-models/**`
- `apps/website/lib/display-objects/**`
- shared formatting helpers like [`time.ts`](apps/website/lib/utilities/time.ts:1)
Actions:
1. Replace locale formatting in Templates.
- Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148)
- Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels.
2. Replace locale formatting in ViewModels.
- Example: [`BillingViewModel.InvoiceViewModel.formattedTotalAmount`](apps/website/lib/view-models/BillingViewModel.ts:85)
- Replace with deterministic formatting helpers (no runtime locale).
3. Remove locale formatting helpers in `lib/utilities`.
- Example: [`formatDate()`](apps/website/lib/utilities/time.ts:48)
- Replace with deterministic formatters.
Deliverable:
- The search pattern `\\bIntl\\.|toLocale` returns zero results for production code.
### 1.5 Enforce write boundary: Server Actions only
**Goal:** no client-initiated writes (no `fetch` POST/PUT/PATCH/DELETE from client components).
Actions:
1. Replace client logout POST with Server Action.
- Example violation: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
- Replace with a Server Action and use `<form action={logoutAction}>`.
- Ensure the action does the mutation and then triggers navigation and/or revalidation.
Deliverable:
- Search for `fetch(` with write methods in client components returns zero hits.
### 1.6 Remove UX Blockers from services (state leakage risk)
**Goal:** services remain stateless and safe regardless of DI scope.
Actions:
1. Remove or relocate blockers from [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95).
- Evidence: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97)
2. Re-introduce blockers at the client boundary (component/hook) where they belong.
Deliverable:
- No stateful blockers stored on service instances.
### 1.7 Consolidate hooks into the canonical folder
**Goal:** one place for React-only helpers: `apps/website/lib/hooks/**`.
Contract:
- Canonical placement is `apps/website/lib/hooks/**` ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32), [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217)).
Actions:
1. Move `apps/website/hooks/**``apps/website/lib/hooks/**`.
2. Update imports across `apps/website/**`.
3. Remove `hooks/` from TS include list in [`tsconfig.json`](apps/website/tsconfig.json:70).
4. Add a guardrail test to fail if `apps/website/hooks/**` reappears.
### 1.8 Tighten the Service boundary (server-safe only)
**Goal:** services become safe to call from server modules (Page Queries) with zero ambiguity.
New non-negotiable rule:
- `apps/website/lib/services/**` returns **API DTOs or Page DTOs only** (JSON-serializable). It MUST NOT import or instantiate anything from `apps/website/lib/view-models/**`.
Why (Clean Architecture + SOLID):
- SRP: services orchestrate IO and composition; they do not prepare UI.
- DIP: server modules depend on service interfaces/DTO contracts, not client-only classes.
- Eliminates the current “maybe server, maybe client” ambiguity.
Actions:
1. Refactor any service returning ViewModels to return DTOs/Page DTOs instead.
2. Move mapping/formatting responsibilities into Presenters colocated with ViewModels.
3. Add a boundary test: forbid `lib/services/**` importing `lib/view-models/**`.
### 1.9 Remove `AdminViewModelService` (Presenter misclassified as Service)
**Goal:** all mapping lives in Presenters, not in services.
Action:
- Replace [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) with a Presenter colocated with its ViewModel(s), then delete the service + test.
### 1.10 Fix the confirmed “PageQuery constructs ViewModel” violation
Evidence:
- [`ProfilePageQuery.execute()`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) resolves [`DriverService`](apps/website/lib/page-queries/ProfilePageQuery.ts:3) and then calls `viewModel.toDTO()` ([`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:54)).
Rule violated:
- Page Queries MUST NOT instantiate ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)).
Action:
- Refactor `DriverService.getDriverProfile()` (and any similar methods) to return Page DTO only when used from server paths.
---
## 2) Route-by-route refactor recipes (concrete file actions)
This section tells you **exactly what to change** for the already-identified hot spots.
### 2.1 `profile/leagues` route
Current violations:
- DI container used on server: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
- Inline server “template” inside `page.tsx`: [`ProfileLeaguesTemplate()`](apps/website/app/profile/leagues/page.tsx:82)
- Server code manipulates ViewModels: [`LeagueSummaryViewModel`](apps/website/app/profile/leagues/page.tsx:13)
Required changes:
1. Create a PageQuery: `apps/website/lib/page-queries/ProfileLeaguesPageQuery.ts`.
- It should:
- Read session (server-side) and determine `currentDriverId`.
- Call API clients directly (manual wiring).
- Assemble a **Page DTO** like:
- `ownedLeagues: Array<{ leagueId; name; description; membershipRole }>`
- `memberLeagues: Array<{ leagueId; name; description; membershipRole }>`
- Return `PageQueryResult`.
2. Replace `apps/website/app/profile/leagues/page.tsx` with composition:
- Call PageQuery.
- Switch on result.
- Render `<ProfileLeaguesPageClient dto={dto} />`.
3. Create `apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx` (`'use client'`).
- Convert Page DTO into ViewData (via Presenter function in `lib/view-models/**`).
- Render a pure Template.
4. Create `apps/website/templates/ProfileLeaguesTemplate.tsx`.
- Props: ViewData only.
- No API calls. No `useMemo`. No filtering/sorting.
### 2.2 `teams` routes
Current violations:
- Server route does sorting/filtering: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12)
- Server route imports ViewModels: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10)
- Team detail server route passes ViewModels into Template: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20)
- Placeholder “currentDriverId” on server: [`const currentDriverId = ''`](apps/website/app/teams/[id]/page.tsx:63)
Required changes:
1. Introduce PageQueries:
- `TeamsPageQuery` returns Page DTO `{ teams: Array<{ id; name; rating; ...raw fields }> }`.
- `TeamDetailPageQuery` returns Page DTO `{ team; memberships; currentDriverId; }` (raw serializable).
2. Move derived computations out of server route.
- `teamsByLevel`, `topTeams`, etc. belong in client ViewModel or PageClient.
3. Add `TeamsPageClient.tsx` and `TeamDetailPageClient.tsx`.
- Instantiate ViewModels client-side.
- Produce ViewData for Templates.
4. Change Templates to take ViewData.
- Stop passing ViewModels into [`TeamDetailTemplate`](apps/website/app/teams/[id]/page.tsx:22).
### 2.3 `dashboard` PageQuery
Current violations:
- Server PageQuery uses singleton DI container: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
- PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6)
Required changes:
1. Move the DTO-like shape out of view-models and into `lib/page-queries/**`.
2. Replace DI with manual wiring.
3. Return `PageQueryResult` as spec.
4. Ensure the result payload is a Page DTO (raw values only).
### 2.4 Logout write boundary
Current violation:
- Client-side POST request: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
Required changes:
1. Create a server action in an appropriate server-only module.
2. Replace the demo-user logout handler with a `<form action={logoutAction}>` submission.
3. Ensure post-logout navigation uses server-side redirect.
---
## 3) What to add to make this enforceable (guardrails)
Minimum guardrails to add:
1. ESLint or test guard: fail on `toLocale*` / `Intl.*` under `apps/website/**` formatting paths.
2. ESLint or test guard: fail if `apps/website/templates/**` imports ViewModels or Display Objects.
3. ESLint or test guard: fail if `apps/website/app/**/page.tsx` imports from `lib/view-models/**`.
4. ESLint or test guard: fail if server code imports [`ContainerManager`](apps/website/lib/di/container.ts:61).
---
## 4) Missing pieces found in the second scan (add to plan)
This section is the “coverage pass”: it enumerates additional concrete hotspots that must be refactored using the recipes above.
### 4.1 Remaining server-side singleton container usage (must be removed)
1. Home SSR data fetch uses container directly
- Evidence: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/services/home/getHomeData.ts:9)
- Action: rewrite [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:8) to use explicit wiring (manual construction) or move the logic into a PageQuery that does manual wiring.
2. Profile page query dynamically imports container and uses singleton
- Evidence: `(await import('@/lib/di/container')).ContainerManager.getInstance().getContainer()` in [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
- Action: ban all container usage in [`apps/website/lib/page-queries/**`](apps/website/lib/page-queries/DashboardPageQuery.ts:1); refactor to manual wiring.
Deliverable:
- Add these files to the “no server DI” guardrail allowlist/banlist:
- [`getHomeData.ts`](apps/website/lib/services/home/getHomeData.ts:1)
- [`ProfilePageQuery.ts`](apps/website/lib/page-queries/ProfilePageQuery.ts:1)
### 4.2 Routes still importing ViewModels from server modules (must be converted to Page DTO + PageClient)
The following are **server route modules** (App Router) that import ViewModels and therefore violate the server/client boundary.
Actions for each: create a PageQuery returning Page DTO, and a `*PageClient.tsx` that builds ViewData and renders the Template.
1. Leaderboards entry route
- Evidence: ViewModel types imported in [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
- Action:
- Replace `*PageWrapper` patterns with a `*PageClient` container.
- Ensure the template receives ViewData only (no ViewModels).
- Remove ViewModel imports from:
- [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
- [`LeaderboardsPageWrapper.tsx`](apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx:4)
2. Driver leaderboards route
- Evidence: ViewModel import in [`leaderboards/drivers/page.tsx`](apps/website/app/leaderboards/drivers/page.tsx:7)
- Action: same as above; rename wrapper to `DriverRankingsPageClient.tsx` and pass ViewData to Templates.
3. Team leaderboard route
- Evidence: ViewModel import in [`teams/leaderboard/page.tsx`](apps/website/app/teams/leaderboard/page.tsx:7) and wrapper import in [`TeamLeaderboardPageWrapper.tsx`](apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx:5)
- Action: same as above.
4. Leagues routes
- Evidence:
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/page.tsx:13)
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/rulebook/page.tsx:13)
- [`LeagueScheduleViewModel`](apps/website/app/leagues/[id]/schedule/page.tsx:13)
- [`LeagueStandingsViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:13) and [`DriverViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:14)
- Action:
- Introduce PageQueries per route: `LeagueDetailPageQuery`, `LeagueRulebookPageQuery`, `LeagueSchedulePageQuery`, `LeagueStandingsPageQuery`.
- PageQueries return Page DTO only.
- Client creates ViewModels + ViewData.
5. Sponsor routes
- Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance: [`AvailableLeaguesViewModel`](apps/website/app/sponsor/leagues/page.tsx:6)
- Action:
- Convert to PageQuery returning Page DTO.
- Move summary computations (counts/averages) to Presenter/ViewModel in client.
6. Races + stewarding routes
- Evidence:
- ViewModel imports in [`races/[id]/page.tsx`](apps/website/app/races/[id]/page.tsx:7)
- Non-canonical hooks usage and ViewModel transformer import in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:5)
- ViewModel import in [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:10)
- Action:
- Convert each to PageQuery + PageClient.
- Move hooks from top-level `apps/website/hooks/**` into `apps/website/lib/hooks/**` (canonical per [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:47)).
- Rename the “transformer” pattern to a Presenter under `lib/view-models/**` and ensure it stays deterministic.
7. Profile routes (beyond `profile/leagues`)
- Evidence: ViewModel import and locale formatting in [`profile/page.tsx`](apps/website/app/profile/page.tsx:16) and `toLocaleDateString` usage (e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430))
- Action: convert to PageQuery + ProfilePageClient + ViewData.
8. Races listing route doing server-side filtering
- Evidence: [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:39) uses `.filter` in route module
- Action: same route split; if filtering is canonical (not UX-only), push it into API; if UX-only, do it client-side in a ViewModel.
### 4.3 “Templates” living under `app/**` (structural and guardrail gap)
The architecture assumes Templates are under `apps/website/templates/**`.
- Evidence: wallet UI is a “Template” in a route folder: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1) and it imports a ViewModel: [`LeagueWalletViewModel`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:7)
Actions:
1. Move this file to `apps/website/templates/LeagueWalletTemplate.tsx`.
2. Ensure it takes ViewData only.
3. Add a guardrail: forbid `*Template.tsx` under `apps/website/app/**`.
### 4.4 Remaining write-boundary violations (client-side POST)
Additional client-side logout write:
- Evidence: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/app/sponsor/settings/page.tsx:178)
Actions:
1. Replace with Server Action flow.
2. Ensure any other write HTTP calls from client code are removed (search pattern already exists).
### 4.5 Locale formatting still present outside Templates (components + lib)
The strict contract forbids locale APIs in formatting paths; the codebase currently uses `toLocale*` heavily in:
- Components (examples):
- [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75)
- [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192)
- [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
- Infrastructure (example):
- [`ErrorReplay.ts`](apps/website/lib/infrastructure/ErrorReplay.ts:214)
- ViewModels (examples):
- [`BillingViewModel`](apps/website/lib/view-models/BillingViewModel.ts:86)
- [`SponsorshipViewModel`](apps/website/lib/view-models/SponsorshipViewModel.ts:48)
- [`LeagueMemberViewModel`](apps/website/lib/view-models/LeagueMemberViewModel.ts:22)
- [`PaymentViewModel`](apps/website/lib/view-models/PaymentViewModel.ts:44)
- [`utilities/time.ts`](apps/website/lib/utilities/time.ts:41)
Actions (make it explicit):
1. Introduce deterministic formatting primitives under `apps/website/lib/display-objects/**` (or a dedicated deterministic formatter module) and route **all** formatting through them.
2. Replace locale formatting calls in ViewModels first (largest fan-out).
3. Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically.
4. Add guardrails for `apps/website/components/**` too (not only Templates), otherwise the same issue migrates.
---
## 5) Third scan: contract checks against the questions
This section explicitly answers:
- do we have any filtering, sorting, formatting or any business logic in pages or ui components?
- do we have clear separation of concerns?
- did you consider correct DI usage?
- did you consider server actions?
### 5.1 Filtering, sorting, formatting, business logic in `app/**`
Contract expectations:
- `app/**/page.tsx` should be server composition only ([`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
- Formatting must be deterministic and must not use `Intl.*` or `toLocale*` ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41)).
Observed violations (representative, not exhaustive):
| Category | Evidence in `app/**` | Why this violates the contract | Required remediation |
|---|---|---|---|
| Filtering/sorting in `page.tsx` | `reduce/filter/sort` in [`computeDerivedData()`](apps/website/app/teams/page.tsx:14) | Server route does presentation shaping (guardrail violation). | Convert to PageQuery + `TeamsPageClient.tsx`; keep `page.tsx` orchestration only. |
| Filtering inside route module | Filtering races in [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:40) | Filtering belongs in API (canonical) or in client ViewModel/PageClient (UX-only), not in route module. | Split route into PageQuery + PageClient. |
| “Authorization-like” decisions in route modules | `isAdmin` derived from role in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:80) and [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:65) | Website may hide UI for UX but must not drift into enforcing security truth ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:186)). These checks are mixed with data/load/render responsibilities. | Ensure these are strictly UI flags derived from API truth and produced in client ViewModels/Presenters. |
| Locale formatting in route modules | `toLocaleDateString/toLocaleString` in [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) and [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:210) | Forbidden locale APIs in formatting paths. | Replace with deterministic formatting, passed via ViewData. |
| ViewModel instantiation in routes | `new DriverViewModel(...)` in [`leagues/[id]/standings/page.tsx`](apps/website/app/leagues/[id]/standings/page.tsx:66) | ViewModels are client-only ([`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)). | Replace with Page DTO; create ViewModels client-side only. |
### 5.2 Filtering, sorting, formatting, business logic in `components/**`
Observed violations (high signal):
| Category | Evidence in `components/**` | Why this violates the contract | Required remediation |
|---|---|---|---|
| Locale formatting in components | `toLocale*` in [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192), [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75), [`NotificationCenter.tsx`](apps/website/components/notifications/NotificationCenter.tsx:84) | Determinism/hydration risk; forbidden formatting path. | Ensure components consume deterministic ViewData strings/numbers produced by Presenters/Display Objects. |
| Client-initiated write calls | `fetch('/api/auth/logout', { method: 'POST' })` in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | Violates Server Action-only write boundary ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)). | Replace with Server Action invoked via `<form action={...}>`. |
### 5.3 Separation of concerns
Answer: **No**; boundaries are blurred.
Concrete evidence:
1. Templates import ViewModels and compute.
- Import example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7)
- Compute example: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
2. Templates exist under `app/**`.
- Example: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1)
3. Services construct ViewModels.
- Example: `return new RaceDetailViewModel(...)` in [`RaceService`](apps/website/lib/services/races/RaceService.ts:24)
- Example: `return ...map(... => new TeamSummaryViewModel(...))` in [`TeamService`](apps/website/lib/services/teams/TeamService.ts:35)
Remediation:
- Server: PageQueries return Page DTO.
- Client: Presenters/ViewModels produce ViewData.
- Templates: ViewData-only, no compute.
### 5.4 DI usage
Answer: **Not correct today**; server uses the singleton container.
Confirmed violations:
- Server route DI: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
- Server PageQuery DI: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
- Server helper enabling DI: [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:21)
- Server service function DI: [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:9)
- Dynamic import DI in PageQuery: [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
Remediation:
1. Add guardrail: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
2. Refactor these call sites to manual wiring or request-scoped container.
### 5.5 Server Actions
Answer: **Not fully enforced**; client-side POSTs exist.
Confirmed violations:
- Client POST logout in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
- Client POST logout in [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67)
- Client POST logout in [`sponsor/settings/page.tsx`](apps/website/app/sponsor/settings/page.tsx:178)
Remediation:
1. Implement a Server Action for logout.
2. Replace all client-side POSTs with `<form action={logoutAction}>`.
3. Add guardrail to forbid `fetch` write methods in client code.
---
## A) DI contract violations (server-side singleton container usage)
@@ -81,6 +617,273 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
---
## 6) Display Objects, Command Models, Blockers (fourth scan)
This section answers:
1) Are Display Objects used as intended?
2) Where should we introduce Display Objects instead of ad-hoc formatting/mapping?
3) Are Command Models used as intended?
4) Where should we introduce Command Models instead of ad-hoc form state/validation?
5) Are Blockers used as intended?
6) Where should we introduce Blockers instead of ad-hoc UX prevention?
### 6.1 Display Objects: current state vs contract
Contract excerpts:
- Display Objects are deterministic UI-only formatting/mapping helpers ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1)).
- Placement rule: `apps/website/lib/display-objects/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:141)).
- Must be class-based, immutable, deterministic ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46)).
- Must NOT call `Intl.*` or `toLocale*` ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63)).
- Templates must not import Display Objects (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
#### 6.1.1 Violations: Display Objects imported from Templates
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| Template imports a Display Object | [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Templates are required to be ViewData-only and must not import display objects. | Move role badge derivation into Presenter/ViewModel and pass `{ text, badgeClasses }` as ViewData. |
#### 6.1.2 Structural drift: “Display Objects” implemented as plain functions/records
The following files live under `lib/display-objects/**`, but they are not class-based value objects (they are exported maps/functions):
- [`DashboardDisplay.ts`](apps/website/lib/display-objects/DashboardDisplay.ts:1)
- [`ProfileDisplay.ts`](apps/website/lib/display-objects/ProfileDisplay.ts:1)
- [`LeagueRoleDisplay.ts`](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1) (partly mitigated via wrapper class, but still map-backed)
This conflicts with the strict “class-based” rule in [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46). If we keep them as functions, we should either:
Clean-code direction (no exceptions):
- Refactor these modules into small, immutable classes with explicit APIs.
- Keep them deterministic and free of locale APIs.
#### 6.1.3 Where Display Objects should be introduced (replace scattered formatting/mapping)
The repo currently performs formatting/mapping directly in Templates, route modules, components, and ViewModels. Those are prime candidates for Display Objects.
High-signal candidates found:
| Pattern | Evidence | Display Object to introduce | What the Display Object should output |
|---|---|---|---|
| Date formatting (`toLocaleDateString`, `toLocaleString`) | Many templates, e.g. [`RaceDetailTemplate.tsx`](apps/website/templates/RaceDetailTemplate.tsx:172), and route code e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) | `DeterministicDateDisplay` (class) | `{ shortDate, longDate, shortTime, dateTime }` as strings based on ISO inputs, deterministic timezone policy.
| Number formatting (`toLocaleString`) | Templates like [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:206) and components like [`MetricCard.tsx`](apps/website/components/sponsors/MetricCard.tsx:49) | `DeterministicNumberDisplay` (class) | `{ compact, integer, fixed2 }` or explicit formatting helpers; avoid runtime locale.
| Currency formatting | ViewModels like [`BillingViewModel.ts`](apps/website/lib/view-models/BillingViewModel.ts:85) | `MoneyDisplay` (class) | `formatted` string(s) given `{ currencyCode, minorUnits }` or `{ currency, amount }`.
| Role badge mapping duplicated outside display objects | Switch in [`MembershipStatus.tsx`](apps/website/components/leagues/MembershipStatus.tsx:33) and also display object usage elsewhere ([`StandingsTable.tsx`](apps/website/components/leagues/StandingsTable.tsx:9)) | Consolidate as `LeagueRoleBadgeDisplay` | `{ text, badgeClasses }` (or `{ text, bg, border, textColor }`), deterministic.
| Sponsorship status + time remaining UI mapping | Derived UI flags in [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:138) | `SponsorshipStatusDisplay` | `{ statusText, statusClasses, isExpiringSoon, daysRemainingLabel }` (pure).
**Important boundary rule:** Display Objects must be used by Presenters/ViewModels, not by Templates. Templates should only see the primitive outputs.
### 6.2 Command Models: current state vs intended use
Contract excerpts:
- Command Models are for transient form state; UX validation only; never derived from ViewModels; never reused from read models ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:61)).
- Canonical placement: `apps/website/lib/command-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:219)).
#### 6.2.1 Good usage already present
- `League wizard` uses a command model: [`LeagueWizardCommandModel`](apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts:79) (consumed in [`CreateLeagueWizard.tsx`](apps/website/components/leagues/CreateLeagueWizard.tsx:27)).
- `Auth` command models exist: [`LoginCommandModel`](apps/website/lib/command-models/auth/LoginCommandModel.ts:17) and [`SignupCommandModel`](apps/website/lib/command-models/auth/SignupCommandModel.ts:21).
- `Protest` write intent uses a command model: [`ProtestDecisionCommandModel`](apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts:10) (constructed in [`protests/[protestId]/page.tsx`](apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx:171)).
#### 6.2.2 Violations / drift: Command-model-like logic living in components
Example: onboarding wizard duplicates field validation inline:
- Validation logic in [`OnboardingWizard.tsx`](apps/website/components/onboarding/OnboardingWizard.tsx:202)
Plan action:
1. Create `apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts`.
2. Move field-level validation + errors to the command model.
3. Keep the component responsible only for UI state (step navigation, show/hide) and invoking the server action.
Similarly, any other form pages that implement repeated validation strings/constraints should be moved into command models.
#### 6.2.3 Server Actions integration: command models should be consumed at the boundary
Contract requires writes to enter via Server Actions.
Plan action (repeatable pattern):
1. UI collects primitives → CommandModel instance (client-only) validates.
2. Submit creates **Command DTO** (plain object) and calls a Server Action.
3. Server Action performs UX validation (not business rules), calls API, redirects/revalidates.
### 6.3 Blockers: current state vs intended use
Contract excerpts:
- Blockers are UX-only, reversible helpers ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7)).
- Client state is UI-only; blockers are not security ([`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:45)).
- Canonical placement: `apps/website/lib/blockers/**` ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:48)).
#### 6.3.1 Violations: Blockers embedded in services (state leakage risk)
- [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores blockers as instance fields: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97).
- [`LeagueWalletService`](apps/website/lib/services/leagues/LeagueWalletService.ts:12) does the same: [`submitBlocker`](apps/website/lib/services/leagues/LeagueWalletService.ts:13) and [`throttle`](apps/website/lib/services/leagues/LeagueWalletService.ts:14).
Why this matters:
- If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state.
Plan action:
1. Remove blockers from service instance state.
2. Reintroduce blockers at the client boundary:
- component local state
- a `useSubmitBlocker()` hook under `apps/website/lib/hooks/**`
- or a per-interaction blocker instance created inside a client function.
#### 6.3.2 Where blockers should be used instead of ad-hoc UX prevention
Candidates:
| UX need | Evidence | Recommended blocker |
|---|---|---|
| Prevent multiple logout clicks | Logout actions exist and currently do client POSTs: [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | `SubmitBlocker` at the UI boundary (button/form) |
| Throttle rapid filter/search updates | Multiple pages render lists with client filtering/sorting, e.g. [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:72) | `ThrottleBlocker` inside a client container or hook (not in service) |
---
## 7) Hooks folder split (`apps/website/hooks` vs `apps/website/lib/hooks`)
Contract:
- React-only utilities MUST live under `apps/website/lib/hooks/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217), [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32)).
Observation:
- Repo contains **two** hooks locations:
- Top-level: [`apps/website/hooks/useCapability.ts`](apps/website/hooks/useCapability.ts:1) (and many more)
- Canonical: [`apps/website/lib/hooks/useEnhancedForm.ts`](apps/website/lib/hooks/useEnhancedForm.ts:1) (and others)
Violation:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| Hooks implemented outside canonical `lib/hooks` | Many hooks under [`apps/website/hooks/**`](apps/website/hooks/useEffectiveDriverId.ts:1) despite contract naming `apps/website/lib/hooks/**` | Confuses imports/boundaries; increases chance hooks depend on route-level concerns or mix server/client incorrectly. Also violates the strict file-structure contract. | Move `apps/website/hooks/**` into `apps/website/lib/hooks/**`, update all imports, remove the top-level folder, and add a guardrail to prevent regression. |
Notes:
- The current `tsconfig` explicitly includes top-level `hooks/` ([`tsconfig.json`](apps/website/tsconfig.json:70)), which suggests this drift is intentional-but-undocumented.
---
## 8) `AdminViewModelService` violates ViewModel instantiation + Presenter placement rules
Contract:
- ViewModels are client-only classes; they are instantiated only in `'use client'` modules ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:76)).
- Presenters (pure mappings) are colocated with ViewModels in `apps/website/lib/view-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:124)).
- Services are for orchestration, not mapping API DTOs into ViewModels (implied by data-flow contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:145)).
Evidence:
- [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:5) is a `lib/services/**` module that performs mapping of API DTOs to ViewModels:
- imports API DTO types from [`AdminApiClient`](apps/website/lib/services/AdminViewModelService.ts:1)
- instantiates `AdminUserViewModel` et al. ([`AdminViewModelService.mapUser()`](apps/website/lib/services/AdminViewModelService.ts:14))
Violations:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| ViewModels instantiated in a non-client module | [`new AdminUserViewModel(dto)`](apps/website/lib/services/AdminViewModelService.ts:15) in a service file with no `'use client'` boundary | If imported by server modules (Page Queries, RSC pages), it breaks the rule that ViewModels are client-only, and risks serializing or executing client-only assumptions on the server. | Move mapping into a Presenter colocated with the ViewModel (e.g. [`AdminUserViewModelPresenter`](apps/website/lib/view-models/AdminUserViewModel.ts:1) or a sibling file) and ensure ViewModel creation happens only in client modules. |
| Presenter logic misclassified as a Service | All methods are pure mapping functions ([`AdminViewModelService.mapUsers()`](apps/website/lib/services/AdminViewModelService.ts:21)) | Encourages importing “services” in server paths, causing architecture erosion (services become dumping grounds). | Rename/rehome as Presenter. Services should call API clients + return Page DTOs, not construct ViewModels. |
---
## 9) Alignment plan (approved direction: align code to contract)
## 9) Alignment plan (Clean Architecture / SOLID, no gaps)
Non-negotiable target:
- `app/**/page.tsx`: server composition only (no business logic, no formatting, no filtering).
- `lib/page-queries/**`: server composition + IO only; returns Page DTO; **no ViewModels**; **no DI singleton**.
- Server DI policy: **no DI at all** in PageQueries or `page.tsx` (manual wiring only).
- `lib/services/**`: server-safe orchestration only; returns API DTO or Page DTO; **no ViewModels**; **no blockers**.
- `lib/view-models/**` + Presenters: client-only; pure mapping to ViewData.
- `templates/**`: dumb renderer; ViewData only.
### 9.1 Guardrails first (prevent regression)
Add CI-failing checks (tests or ESLint rules). These come first to enforce Clean Architecture boundaries while refactors are in flight:
1. `app/**/page.tsx` MUST NOT import from `apps/website/lib/view-models/**`.
2. `templates/**` MUST NOT import from `apps/website/lib/view-models/**` nor `apps/website/lib/display-objects/**`.
3. `lib/page-queries/**` MUST NOT import from `apps/website/lib/view-models/**`.
4. `lib/services/**` MUST NOT import from `apps/website/lib/view-models/**`.
5. Forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
6. Forbid `Intl.*` and `toLocale*` in all presentation paths.
7. Forbid `*Template.tsx` under `apps/website/app/**`.
Order of implementation (guardrails):
1. Server DI ban: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61) and forbid use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) from server modules.
2. Server/client boundary: forbid any imports of `apps/website/lib/view-models/**` from `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**`.
3. Template boundary: forbid imports of view-models and display-objects from `apps/website/templates/**`.
4. Service boundary: forbid imports of view-models from `apps/website/lib/services/**`.
5. Determinism: forbid `Intl.*` and `toLocale*` in all presentation paths.
6. Write boundary: forbid client-side `fetch` with write methods in client modules.
These align cleanly with SRP (single reason to change per layer) and remove ambiguity.
### 9.2 Structural cleanups
1. Move all hooks to `apps/website/lib/hooks/**` (see §7 and §1.7).
2. Convert Display Objects to class-based, immutable modules (see §6.1.2).
3. Delete [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) and replace with a Presenter.
### 9.3 Service boundary refactor (DTO-only)
1. For each service currently returning ViewModels (examples: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95), [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:12), [`MediaService`](apps/website/lib/services/media/MediaService.ts:15)):
- Change outputs to API DTOs or Page DTOs.
- Move any ViewModel instantiation into client Presenters.
- Remove blockers/state from services.
2. Fix the confirmed server/ViewModel leak:
- [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) must not call `viewModel.toDTO()`.
- It must call a service returning Page DTO and return that directly.
### 9.4 Route refactor recipe (repeatable)
For every route currently doing logic in `page.tsx`:
1. `page.tsx`: fetches via PageQuery, switches on result, renders a `*PageClient`.
2. PageQuery: calls services + assembles Page DTO only.
3. `*PageClient.tsx`: instantiates ViewModels (client-only) and produces ViewData via Presenters.
4. Template: renders ViewData only.
Write path (per-route):
1. `app/<route>/actions.ts`: Server Actions for that route only (mutations, UX validation, redirect/revalidate).
2. `*PageClient.tsx` (or a client component) uses `<form action={...}>` to invoke the action.
3. Services called by actions remain DTO-only and do not contain UI logic.
This is the mechanism that keeps pages free from business logic and leaves no seams for drift.
### 9.5 ViewModels: allowed, but only behind the client boundary
You chose: keep ViewModels, enforce client-only.
Non-negotiable rules (to avoid gaps):
1. No server module imports from `apps/website/lib/view-models/**`.
2. No `apps/website/lib/services/**` imports from `apps/website/lib/view-models/**`.
3. A ViewModel instance is never passed into a Template.
4. A ViewModel instance is never serialized.
Allowed flows:
- Page DTO → Presenter → ViewData → Template
- Page DTO → Presenter → ViewModel → Presenter → ViewData → Template
This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels).
---
## High-signal file sets (pattern-based indicators)
### Templates importing ViewModels and or Display Objects (forbidden)
@@ -100,4 +903,3 @@ This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
- API client exists at root `lib/` instead of `lib/api/`: [`apiClient.ts`](apps/website/lib/apiClient.ts:1)
- Non-canonical `lib/page/` exists: [`PageDataFetcher.ts`](apps/website/lib/page/PageDataFetcher.ts:1)