1060 lines
63 KiB
Markdown
1060 lines
63 KiB
Markdown
# Website Architecture Violations Report
|
||
|
||
Scope: `apps/website/**` aligned against `docs/architecture/website/**`, with the authoritative contract at [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1).
|
||
|
||
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)).
|
||
|
||
### 0.1 Naming: stop calling everything “dto” (clarify the model taxonomy)
|
||
|
||
This repo currently uses `dto` as a generic variable name, which collapses three different concepts.
|
||
|
||
We will use the contract’s model names and enforce naming conventions to prevent category errors.
|
||
|
||
Authoritative types:
|
||
|
||
- **API Transport DTO**: returned by `apps/api` over HTTP. In this repo, often generated from OpenAPI.
|
||
- Canonical placement: [`apps/website/lib/types/**`](apps/website/lib/types/League.ts:1) per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:41).
|
||
- Naming rule:
|
||
- Type names end with `ApiDto` (preferred) or `DTO` (existing generated types).
|
||
- Variable names: `apiDto`, `apiResponse`.
|
||
|
||
- **Page DTO**: website-owned server-to-client payload assembled by PageQueries.
|
||
- Canonical placement: `apps/website/lib/page-queries/**` per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:54).
|
||
- Naming rule:
|
||
- Type names end with `PageDto`.
|
||
- Variable names: `pageDto`.
|
||
|
||
- **ViewData**: the only allowed input to Templates.
|
||
- Canonical placement: `apps/website/templates/**` per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:86).
|
||
- Naming rule:
|
||
- Type names end with `ViewData`.
|
||
- Variable names: `viewData`.
|
||
|
||
Other model names we should keep explicit:
|
||
|
||
- **ViewModel**: client-only class instance.
|
||
- Naming rule: `*ViewModel` types/classes, variables `viewModel`.
|
||
|
||
- **Command DTO** (write intent): the payload sent to Server Actions / API for mutations.
|
||
- Naming rule: `*CommandDto`, variables `commandDto`.
|
||
|
||
Non-negotiable rule:
|
||
|
||
- Never use a bare `dto` variable name. Use `apiDto`, `pageDto`, `viewData`, or `commandDto`.
|
||
|
||
This is a Clean Architecture guardrail: when names are precise, wrong-layer dependencies become obvious during review.
|
||
|
||
### 0.2 Enforce the taxonomy with required abstractions (not lint)
|
||
|
||
You asked for “proper abstractions that must be implemented” (interfaces and/or abstract classes), rather than naming-only or ESLint enforcement.
|
||
|
||
The goal is that the *compiler* (and the module boundaries) makes it hard to mix:
|
||
|
||
- API Transport DTO vs Page DTO vs ViewData
|
||
- server-only vs client-only
|
||
- orchestration vs presentation mapping
|
||
|
||
Proposed required abstractions (website-owned):
|
||
|
||
1. **API Client contracts** (transport boundary)
|
||
- Interface per API area, returns **ApiDto/DTO only**.
|
||
- Example: `LeaguesApiClient` already exists; enforce that it returns `*DTO` types only.
|
||
- Prohibit returning ViewModels.
|
||
|
||
2. **Service contracts** (orchestration boundary, server-safe)
|
||
- Interface per feature: `*Service` returns **ApiDto** or **PageDto** only.
|
||
- Must be stateless.
|
||
- Must not import `lib/view-models/**`.
|
||
|
||
3. **PageQuery contract** (server composition boundary)
|
||
- A generic interface:
|
||
- `execute(params) -> PageQueryResult<PageDto>`
|
||
- PageQueries must only depend on API clients + services (manual wiring).
|
||
- Must not import `lib/view-models/**`.
|
||
|
||
4. **Presenter contract** (pure mapping boundary, client-only)
|
||
- Presenter is a pure mapper:
|
||
- `PageDto -> ViewData` OR `PageDto -> ViewModel` OR `ViewModel -> ViewData`
|
||
- No HTTP, no side effects.
|
||
|
||
5. **ViewModel base** (client-only)
|
||
- Optional but useful to enforce non-serializability:
|
||
- e.g. `abstract class ViewModel { abstract toViewData(): ViewData }`
|
||
- No `toDTO()` method that resembles server DTOs (it tempts server usage).
|
||
|
||
6. **DisplayObject base** (deterministic formatting)
|
||
- Abstract base or interface to enforce “no locale APIs” policy by convention and review.
|
||
- Encourages `new MoneyDisplay(amount).formatted()` style, but only used from Presenters/ViewModels.
|
||
|
||
Implementation note:
|
||
|
||
- These abstractions live in `apps/website/lib/contracts/**` so every feature must conform.
|
||
- This is complementary to guardrails: guardrails prevent forbidden imports; contracts ensure the correct “shape” of each layer.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
Rationale: why DI is banned on the server side
|
||
|
||
This is a deliberate safety simplification.
|
||
|
||
1. Next.js server execution is concurrent; DI containers make it easy to accidentally share state across requests.
|
||
- The contract explicitly warns about this risk ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:227), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:9)).
|
||
- The singleton container is explicitly called out as unsafe ([`ContainerManager`](apps/website/lib/di/container.ts:61), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:33)).
|
||
|
||
2. The codebase already contains stateful “service” patterns (e.g. blockers stored as instance fields), which are harmless in a per-request graph but dangerous in a shared container.
|
||
- Example: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96).
|
||
|
||
3. Clean Architecture intent: server modules are composition roots; manual wiring makes dependencies explicit and reviewable.
|
||
- This reduces hidden coupling and prevents accidental imports of client-only types like `lib/view-models/**`.
|
||
|
||
Note: the strict contract technically allows request-scoped server DI ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:25)), but we choose the stronger rule (manual wiring only) to eliminate an entire class of cross-request bugs and enforcement ambiguity.
|
||
|
||
### 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 any service method used by a PageQuery that currently returns a ViewModel to return a Page DTO instead.
|
||
|
||
---
|
||
|
||
## 12) Generic integrity rules for untrusted transport data (no case studies)
|
||
|
||
This is the durable architectural rule behind the “`as` looks vulnerable” concern.
|
||
|
||
### 12.1 Rule: treat API Transport DTO values as untrusted input
|
||
|
||
Even with OpenAPI generation, runtime values can drift (backend bug, contract mismatch, migrations, older clients).
|
||
|
||
Therefore:
|
||
|
||
- Never use `as SomeClosedUnion` on fields coming from an API response.
|
||
- Never assume string enums are safe.
|
||
|
||
### 12.2 Where validation/coercion belongs
|
||
|
||
- **API Transport DTO** remains raw (what the API sent).
|
||
- **Page DTO** can remain raw but should be structurally stable.
|
||
- **Presenter/ViewModel** is the correct place to normalize/coerce *for UI resilience*.
|
||
|
||
This keeps the website as a delivery layer: we’re not enforcing business rules; we’re preventing UI crashes.
|
||
|
||
### 12.3 Required pattern: parsers for string unions
|
||
|
||
Define small pure parsers (in a Presenter-adjacent module) for every “closed set” field:
|
||
|
||
- `parseSocialPlatform(value: unknown): SocialPlatform | 'unknown'`
|
||
- `parseAchievementIcon(value: unknown): AchievementIcon | 'unknown'`
|
||
- `parseAchievementRarity(value: unknown): AchievementRarity | 'unknown'`
|
||
|
||
Policy (agreed):
|
||
|
||
- ViewModel keeps `'unknown'` for debugging/telemetry.
|
||
- ViewData omits unknown items (UI stays clean).
|
||
|
||
This keeps code safe without turning the website into a second source of truth (the API still owns validation).
|
||
|
||
---
|
||
|
||
## 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)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| Server `app/**/page.tsx` MUST NOT access the DI container ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:231), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:15)) | [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45) | Server route is directly resolving from a singleton container; violates the client-first DI contract and request-safety. | Replace with a PageQuery + manual per-request wiring, or a request-scoped container factory. |
|
||
| `ContainerManager.getContainer()` is client-only ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:35)) | [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) calls [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page/PageDataFetcher.ts:21) | This helper enables forbidden server access to the singleton container. | Ban server use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15). Use explicit construction in PageQueries. |
|
||
| PageQueries may use DI only if request-scoped and stateless ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:20)) | [`DashboardPageQuery.execute()`](apps/website/lib/page-queries/DashboardPageQuery.ts:108) uses [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110) | PageQuery runs on the server; the singleton container is explicitly unsafe for concurrent server requests. | Convert to manual wiring (construct API client + service per call), or create a fresh container per request. |
|
||
|
||
---
|
||
|
||
## B) RSC route structure violations (page.tsx doing more than composition)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| `page.tsx` does composition only ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:26), [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50)) | Data fetch + categorization + inline template in a server route module: [`fetchProfileLeaguesData()`](apps/website/app/profile/leagues/page.tsx:22) and [`ProfileLeaguesTemplate()`](apps/website/app/profile/leagues/page.tsx:82) | Responsibilities are mixed: server data fetching, decision logic, and rendering composition in one module. | Split into per-route shape: `page.tsx` calls a PageQuery and passes Page DTO; a `*PageClient.tsx` builds ViewData; Template renders ViewData only. |
|
||
| PageQueries MUST return the documented discriminated union ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17)) | Local type differs from spec: [`PageQueryResult<TData>`](apps/website/lib/page-queries/DashboardPageQuery.ts:11) uses `data` and `destination` | Breaks the documented contract (`ok` with `dto`, `redirect` with `to`). | Standardize a single `PageQueryResult` type per [`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17) and enforce across queries. |
|
||
|
||
---
|
||
|
||
## C) ViewModel boundary violations (ViewModels used/created on server and/or passed into Templates)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| ViewModels are client-only and never serialized ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:70), [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)) | Server route data types include ViewModels: [`TeamDetailData.team: TeamDetailsViewModel`](apps/website/app/teams/[id]/page.tsx:15) and are passed into Template wrapper: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20) | Implies ViewModels cross the server→client boundary, contradicting the “client-only class” rule and risking serialization/hydration issues. | Use Page DTOs for server-to-client, and instantiate ViewModels only in `'use client'` modules. |
|
||
| Templates accept ViewData only ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:86), [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:3)) | Template prop uses ViewModel instances: [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19) | Template is no longer receiving ViewData (JSON-serializable primitive shapes). | Introduce Presenters that map Page DTO → ViewData and pass ViewData into Templates. |
|
||
| PageQueries must not instantiate client-only types / ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)) | PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6) | `lib/view-models/**` is the client-only boundary (also where Presenters live per [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:50)). | Move Page DTO types under `lib/page-queries/**` and keep view-model types under `lib/view-models/**`. |
|
||
|
||
---
|
||
|
||
## D) Template purity violations (Templates importing forbidden layers and doing computation)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| Templates MUST NOT import ViewModels ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7) | Template depends on client-only classes, drifting into Presenter/ViewModel responsibilities. | Replace ViewModel props with ViewData props. |
|
||
| Templates MUST NOT import Display Objects ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Display Objects are forbidden inside Templates. | Use Presenters/ViewModels to emit primitive strings into ViewData. |
|
||
| Templates MUST NOT compute derived values ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:33)) | Filtering/sorting occurs inside Template: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81) | Template performs orchestration and non-trivial computation rather than rendering prepared ViewData. | Move to ViewModels (client) and/or Presenters; Templates render only. |
|
||
| “Templates are pure” ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:27)) | Hooks in templates: [`useMemo()`](apps/website/templates/RacesTemplate.tsx:3) used for filtering/grouping | Pure-template contract is violated by stateful/reactive computations. | Create a `*PageClient.tsx` container for state/computation; keep template as pure function over ViewData. |
|
||
|
||
---
|
||
|
||
## E) Determinism violations: forbidden locale/time formatting paths (`Intl.*`, `toLocale*`)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| Templates must not call locale APIs ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148) | Locale/timezone-dependent formatting can differ between SSR and browser, risking hydration mismatches and non-determinism. | Replace with deterministic formatting via Display Objects, or API-provided labels, passed through Presenters into ViewData. |
|
||
| Formatting code paths must not use locale APIs ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63), [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:37)) | Example: [`toLocaleString()`](apps/website/lib/view-models/BillingViewModel.ts:86) | Even though ViewModels may format, this repo’s strict determinism rules forbid runtime-locale APIs. | Replace with deterministic numeric/date formatting helpers (no `Intl.*`, no `toLocale*`). |
|
||
| Utility helpers used in presentation must not use locale APIs ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:7)) | [`formatDate()`](apps/website/lib/utilities/time.ts:48) calls [`toLocaleDateString()`](apps/website/lib/utilities/time.ts:50) | Utilities become hidden sources of non-determinism across the app. | Replace these helpers with deterministic formatters and enforce via guardrail tests/ESLint. |
|
||
|
||
---
|
||
|
||
## F) Page.tsx guardrail violations (sorting/filtering in server routes and forbidden imports)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| RSC `page.tsx` must not perform sorting/filtering beyond trivial checks ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)) | Server route computes derived values via reduce/filter/sort: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12) | Server route contains presentation shaping. | Move shaping into PageQuery output (as Page DTO only) and/or into client ViewModel/Presenter. |
|
||
| RSC `page.tsx` must not import ViewModels ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)) | Example: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10) | ViewModels are client-only. | Replace with Page DTO types; construct ViewModels client-side. |
|
||
|
||
---
|
||
|
||
## G) Write-flow violations (writes not entering through Server Actions)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| All writes MUST enter through Server Actions ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:171), [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)) | Client component performs POST: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212) | Client-initiated writes are explicitly forbidden. | Replace with a Server Action invoked via `<form action={...}>` or button action, then revalidate/navigate. |
|
||
|
||
---
|
||
|
||
## H) UX-only blockers embedded in services (state leakage risk)
|
||
|
||
| Rule | Evidence | Why this is a violation | Fix direction |
|
||
|---|---|---|---|
|
||
| Blockers are UX-only and should be local + reversible ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7), [`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:7)) | Stateful blockers inside a service: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97) | If the service is DI-singleton or reused across requests, this state can leak across users/requests; also mixes UI concerns into a service boundary. | Move blockers into client UI boundary (hook/component) or ensure strict client-only, per-instance usage. Services should remain stateless. |
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
Naming enforcement (applies across all refactors):
|
||
|
||
- API response shapes are `ApiDto`/`DTO` and stored/handled as `apiDto`.
|
||
- PageQuery output is always named `pageDto` and typed `*PageDto`.
|
||
- Template props are always `*ViewData` and passed as `viewData`.
|
||
|
||
Abstractions enforcement (applies across all refactors):
|
||
|
||
- Every PageQuery implements a common `PageQuery<PageDto>` contract (in `lib/contracts/page-queries/**`).
|
||
- Every service implements a `Service` interface that returns `ApiDto`/`PageDto` only (in `lib/contracts/services/**`).
|
||
- Every Presenter implements `Presenter<Input, Output>` with `Output` being `ViewData` or `ViewModel` (in `lib/contracts/presenters/**`).
|
||
- ViewModels extend a shared base (in `lib/contracts/view-models/**`) to discourage accidental server serialization patterns.
|
||
|
||
### 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)
|
||
|
||
- [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:7)
|
||
- [`LeagueDetailTemplate.tsx`](apps/website/templates/LeagueDetailTemplate.tsx:13)
|
||
- [`LeagueStandingsTemplate.tsx`](apps/website/templates/LeagueStandingsTemplate.tsx:6)
|
||
- [`TeamDetailTemplate.tsx`](apps/website/templates/TeamDetailTemplate.tsx:14)
|
||
|
||
### Server routes importing ViewModels and or doing formatting/filtering (forbidden/discouraged)
|
||
|
||
- [`teams/page.tsx`](apps/website/app/teams/page.tsx:12)
|
||
- [`teams/[id]/page.tsx`](apps/website/app/teams/[id]/page.tsx:9)
|
||
- [`profile/leagues/page.tsx`](apps/website/app/profile/leagues/page.tsx:7)
|
||
|
||
### Structural drift vs canonical `lib/*` layout
|
||
|
||
- 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)
|