# 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//page.tsx server: composition only lib/page-queries/... server: fetch + assemble Page DTO + return PageQueryResult app//PageClient.tsx client: ViewData creation + client state templates/Template.tsx client: pure UI, ViewData-only, no computation lib/view-models/** client-only: ViewModels + Presenters (pure) lib/display-objects/** deterministic formatting helpers (no locale APIs) ``` Hard boundaries to enforce: - No server code imports from `lib/view-models/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)). - Templates import neither `lib/view-models/**` nor `lib/display-objects/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)). - No locale APIs (`Intl.*`, `toLocale*`) in any formatting path (guardrails: [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:13)). - All writes enter via Server Actions (contract: [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)). ### 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` - 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`](apps/website/lib/page-queries/DashboardPageQuery.ts:11) Deliverable: - All pages use `switch(result.status)` and call `notFound()` / `redirect()` only from `page.tsx`. ### 1.3 Fix Template purity (ViewData-only, no imports, no compute) **Goal:** Templates become “dumb” renderers: no `useMemo`, no filtering/sorting, no ViewModel or DisplayObject imports. Actions: 1. Remove ViewModel props from Templates. - Example: replace [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19) with `drivers: DriverRankingsViewData[]` (a ViewData type). 2. Remove compute from Templates. - Example: move [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81) into: - a ViewModel (client) OR - a Presenter (client) OR - the PageClient container (client) if it is driven by UI state. 3. Remove DisplayObject usage from Templates. - Example violation: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) - Replace with primitive values in ViewData (badge label, badge classes). Deliverable: - Guardrail tests (or ESLint) that fail if any `apps/website/templates/**` imports from: - `apps/website/lib/view-models/**` - `apps/website/lib/display-objects/**` ### 1.4 Eliminate locale APIs from all formatting paths **Goal:** zero usage of `Intl.*` and `toLocale*` in: - `apps/website/templates/**` - `apps/website/app/**/page.tsx` - `apps/website/lib/view-models/**` - `apps/website/lib/display-objects/**` - shared formatting helpers like [`time.ts`](apps/website/lib/utilities/time.ts:1) Actions: 1. Replace locale formatting in Templates. - Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148) - Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels. 2. Replace locale formatting in ViewModels. - Example: [`BillingViewModel.InvoiceViewModel.formattedTotalAmount`](apps/website/lib/view-models/BillingViewModel.ts:85) - Replace with deterministic formatting helpers (no runtime locale). 3. Remove locale formatting helpers in `lib/utilities`. - Example: [`formatDate()`](apps/website/lib/utilities/time.ts:48) - Replace with deterministic formatters. Deliverable: - The search pattern `\\bIntl\\.|toLocale` returns zero results for production code. ### 1.5 Enforce write boundary: Server Actions only **Goal:** no client-initiated writes (no `fetch` POST/PUT/PATCH/DELETE from client components). Actions: 1. Replace client logout POST with Server Action. - Example violation: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212) - Replace with a Server Action and use `
`. - Ensure the action does the mutation and then triggers navigation and/or revalidation. Deliverable: - Search for `fetch(` with write methods in client components returns zero hits. ### 1.6 Remove UX Blockers from services (state leakage risk) **Goal:** services remain stateless and safe regardless of DI scope. Actions: 1. Remove or relocate blockers from [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95). - Evidence: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97) 2. Re-introduce blockers at the client boundary (component/hook) where they belong. Deliverable: - No stateful blockers stored on service instances. ### 1.7 Consolidate hooks into the canonical folder **Goal:** one place for React-only helpers: `apps/website/lib/hooks/**`. Contract: - Canonical placement is `apps/website/lib/hooks/**` ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32), [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217)). Actions: 1. Move `apps/website/hooks/**` → `apps/website/lib/hooks/**`. 2. Update imports across `apps/website/**`. 3. Remove `hooks/` from TS include list in [`tsconfig.json`](apps/website/tsconfig.json:70). 4. Add a guardrail test to fail if `apps/website/hooks/**` reappears. ### 1.8 Tighten the Service boundary (server-safe only) **Goal:** services become safe to call from server modules (Page Queries) with zero ambiguity. New non-negotiable rule: - `apps/website/lib/services/**` returns **API DTOs or Page DTOs only** (JSON-serializable). It MUST NOT import or instantiate anything from `apps/website/lib/view-models/**`. Why (Clean Architecture + SOLID): - SRP: services orchestrate IO and composition; they do not prepare UI. - DIP: server modules depend on service interfaces/DTO contracts, not client-only classes. - Eliminates the current “maybe server, maybe client” ambiguity. Actions: 1. Refactor any service returning ViewModels to return DTOs/Page DTOs instead. 2. Move mapping/formatting responsibilities into Presenters colocated with ViewModels. 3. Add a boundary test: forbid `lib/services/**` importing `lib/view-models/**`. ### 1.9 Remove `AdminViewModelService` (Presenter misclassified as Service) **Goal:** all mapping lives in Presenters, not in services. Action: - Replace [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) with a Presenter colocated with its ViewModel(s), then delete the service + test. ### 1.10 Fix the confirmed “PageQuery constructs ViewModel” violation Evidence: - [`ProfilePageQuery.execute()`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) resolves [`DriverService`](apps/website/lib/page-queries/ProfilePageQuery.ts:3) and then calls `viewModel.toDTO()` ([`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:54)). Rule violated: - Page Queries MUST NOT instantiate ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)). Action: - Refactor 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 ``. 3. Create `apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx` (`'use client'`). - Convert Page DTO into ViewData (via Presenter function in `lib/view-models/**`). - Render a pure Template. 4. Create `apps/website/templates/ProfileLeaguesTemplate.tsx`. - Props: ViewData only. - No API calls. No `useMemo`. No filtering/sorting. ### 2.2 `teams` routes Current violations: - Server route does sorting/filtering: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12) - Server route imports ViewModels: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10) - Team detail server route passes ViewModels into Template: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20) - Placeholder “currentDriverId” on server: [`const currentDriverId = ''`](apps/website/app/teams/[id]/page.tsx:63) Required changes: 1. Introduce PageQueries: - `TeamsPageQuery` returns Page DTO `{ teams: Array<{ id; name; rating; ...raw fields }> }`. - `TeamDetailPageQuery` returns Page DTO `{ team; memberships; currentDriverId; }` (raw serializable). 2. Move derived computations out of server route. - `teamsByLevel`, `topTeams`, etc. belong in client ViewModel or PageClient. 3. Add `TeamsPageClient.tsx` and `TeamDetailPageClient.tsx`. - Instantiate ViewModels client-side. - Produce ViewData for Templates. 4. Change Templates to take ViewData. - Stop passing ViewModels into [`TeamDetailTemplate`](apps/website/app/teams/[id]/page.tsx:22). ### 2.3 `dashboard` PageQuery Current violations: - Server PageQuery uses singleton DI container: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110) - PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6) Required changes: 1. Move the DTO-like shape out of view-models and into `lib/page-queries/**`. 2. Replace DI with manual wiring. 3. Return `PageQueryResult` as spec. 4. Ensure the result payload is a Page DTO (raw values only). ### 2.4 Logout write boundary Current violation: - Client-side POST request: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212) Required changes: 1. Create a server action in an appropriate server-only module. 2. Replace the demo-user logout handler with a `` submission. 3. Ensure post-logout navigation uses server-side redirect. --- ## 3) What to add to make this enforceable (guardrails) Minimum guardrails to add: 1. ESLint or test guard: fail on `toLocale*` / `Intl.*` under `apps/website/**` formatting paths. 2. ESLint or test guard: fail if `apps/website/templates/**` imports ViewModels or Display Objects. 3. ESLint or test guard: fail if `apps/website/app/**/page.tsx` imports from `lib/view-models/**`. 4. ESLint or test guard: fail if server code imports [`ContainerManager`](apps/website/lib/di/container.ts:61). --- ## 4) Missing pieces found in the second scan (add to plan) This section is the “coverage pass”: it enumerates additional concrete hotspots that must be refactored using the recipes above. ### 4.1 Remaining server-side singleton container usage (must be removed) 1. Home SSR data fetch uses container directly - Evidence: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/services/home/getHomeData.ts:9) - Action: rewrite [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:8) to use explicit wiring (manual construction) or move the logic into a PageQuery that does manual wiring. 2. Profile page query dynamically imports container and uses singleton - Evidence: `(await import('@/lib/di/container')).ContainerManager.getInstance().getContainer()` in [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43) - Action: ban all container usage in [`apps/website/lib/page-queries/**`](apps/website/lib/page-queries/DashboardPageQuery.ts:1); refactor to manual wiring. Deliverable: - Add these files to the “no server DI” guardrail allowlist/banlist: - [`getHomeData.ts`](apps/website/lib/services/home/getHomeData.ts:1) - [`ProfilePageQuery.ts`](apps/website/lib/page-queries/ProfilePageQuery.ts:1) ### 4.2 Routes still importing ViewModels from server modules (must be converted to Page DTO + PageClient) The following are **server route modules** (App Router) that import ViewModels and therefore violate the server/client boundary. Actions for each: create a PageQuery returning Page DTO, and a `*PageClient.tsx` that builds ViewData and renders the Template. 1. Leaderboards entry route - Evidence: ViewModel types imported in [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8) - Action: - Replace `*PageWrapper` patterns with a `*PageClient` container. - Ensure the template receives ViewData only (no ViewModels). - Remove ViewModel imports from: - [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8) - [`LeaderboardsPageWrapper.tsx`](apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx:4) 2. Driver leaderboards route - Evidence: ViewModel import in [`leaderboards/drivers/page.tsx`](apps/website/app/leaderboards/drivers/page.tsx:7) - Action: same as above; rename wrapper to `DriverRankingsPageClient.tsx` and pass ViewData to Templates. 3. Team leaderboard route - Evidence: ViewModel import in [`teams/leaderboard/page.tsx`](apps/website/app/teams/leaderboard/page.tsx:7) and wrapper import in [`TeamLeaderboardPageWrapper.tsx`](apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx:5) - Action: same as above. 4. Leagues routes - Evidence: - [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/page.tsx:13) - [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/rulebook/page.tsx:13) - [`LeagueScheduleViewModel`](apps/website/app/leagues/[id]/schedule/page.tsx:13) - [`LeagueStandingsViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:13) and [`DriverViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:14) - Action: - Introduce PageQueries per route: `LeagueDetailPageQuery`, `LeagueRulebookPageQuery`, `LeagueSchedulePageQuery`, `LeagueStandingsPageQuery`. - PageQueries return Page DTO only. - Client creates ViewModels + ViewData. 5. Sponsor routes - Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance: [`AvailableLeaguesViewModel`](apps/website/app/sponsor/leagues/page.tsx:6) - Action: - Convert to PageQuery returning Page DTO. - Move summary computations (counts/averages) to Presenter/ViewModel in client. 6. Races + stewarding routes - Evidence: - ViewModel imports in [`races/[id]/page.tsx`](apps/website/app/races/[id]/page.tsx:7) - Non-canonical hooks usage and ViewModel transformer import in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:5) - ViewModel import in [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:10) - Action: - Convert each to PageQuery + PageClient. - Move hooks from top-level `apps/website/hooks/**` into `apps/website/lib/hooks/**` (canonical per [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:47)). - Rename the “transformer” pattern to a Presenter under `lib/view-models/**` and ensure it stays deterministic. 7. Profile routes (beyond `profile/leagues`) - Evidence: ViewModel import and locale formatting in [`profile/page.tsx`](apps/website/app/profile/page.tsx:16) and `toLocaleDateString` usage (e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430)) - Action: convert to PageQuery + ProfilePageClient + ViewData. 8. Races listing route doing server-side filtering - Evidence: [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:39) uses `.filter` in route module - Action: same route split; if filtering is canonical (not UX-only), push it into API; if UX-only, do it client-side in a ViewModel. ### 4.3 “Templates” living under `app/**` (structural and guardrail gap) The architecture assumes Templates are under `apps/website/templates/**`. - Evidence: wallet UI is a “Template” in a route folder: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1) and it imports a ViewModel: [`LeagueWalletViewModel`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:7) Actions: 1. Move this file to `apps/website/templates/LeagueWalletTemplate.tsx`. 2. Ensure it takes ViewData only. 3. Add a guardrail: forbid `*Template.tsx` under `apps/website/app/**`. ### 4.4 Remaining write-boundary violations (client-side POST) Additional client-side logout write: - Evidence: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/app/sponsor/settings/page.tsx:178) Actions: 1. Replace with Server Action flow. 2. Ensure any other write HTTP calls from client code are removed (search pattern already exists). ### 4.5 Locale formatting still present outside Templates (components + lib) The strict contract forbids locale APIs in formatting paths; the codebase currently uses `toLocale*` heavily in: - Components (examples): - [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75) - [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192) - [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) - Infrastructure (example): - [`ErrorReplay.ts`](apps/website/lib/infrastructure/ErrorReplay.ts:214) - ViewModels (examples): - [`BillingViewModel`](apps/website/lib/view-models/BillingViewModel.ts:86) - [`SponsorshipViewModel`](apps/website/lib/view-models/SponsorshipViewModel.ts:48) - [`LeagueMemberViewModel`](apps/website/lib/view-models/LeagueMemberViewModel.ts:22) - [`PaymentViewModel`](apps/website/lib/view-models/PaymentViewModel.ts:44) - [`utilities/time.ts`](apps/website/lib/utilities/time.ts:41) Actions (make it explicit): 1. Introduce deterministic formatting primitives under `apps/website/lib/display-objects/**` (or a dedicated deterministic formatter module) and route **all** formatting through them. 2. Replace locale formatting calls in ViewModels first (largest fan-out). 3. Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically. 4. Add guardrails for `apps/website/components/**` too (not only Templates), otherwise the same issue migrates. --- ## 5) Third scan: contract checks against the questions This section explicitly answers: - do we have any filtering, sorting, formatting or any business logic in pages or ui components? - do we have clear separation of concerns? - did you consider correct DI usage? - did you consider server actions? ### 5.1 Filtering, sorting, formatting, business logic in `app/**` Contract expectations: - `app/**/page.tsx` should be server composition only ([`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)). - Formatting must be deterministic and must not use `Intl.*` or `toLocale*` ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41)). Observed violations (representative, not exhaustive): | Category | Evidence in `app/**` | Why this violates the contract | Required remediation | |---|---|---|---| | Filtering/sorting in `page.tsx` | `reduce/filter/sort` in [`computeDerivedData()`](apps/website/app/teams/page.tsx:14) | Server route does presentation shaping (guardrail violation). | Convert to PageQuery + `TeamsPageClient.tsx`; keep `page.tsx` orchestration only. | | Filtering inside route module | Filtering races in [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:40) | Filtering belongs in API (canonical) or in client ViewModel/PageClient (UX-only), not in route module. | Split route into PageQuery + PageClient. | | “Authorization-like” decisions in route modules | `isAdmin` derived from role in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:80) and [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:65) | Website may hide UI for UX but must not drift into enforcing security truth ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:186)). These checks are mixed with data/load/render responsibilities. | Ensure these are strictly UI flags derived from API truth and produced in client ViewModels/Presenters. | | Locale formatting in route modules | `toLocaleDateString/toLocaleString` in [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) and [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:210) | Forbidden locale APIs in formatting paths. | Replace with deterministic formatting, passed via ViewData. | | ViewModel instantiation in routes | `new DriverViewModel(...)` in [`leagues/[id]/standings/page.tsx`](apps/website/app/leagues/[id]/standings/page.tsx:66) | ViewModels are client-only ([`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)). | Replace with Page DTO; create ViewModels client-side only. | ### 5.2 Filtering, sorting, formatting, business logic in `components/**` Observed violations (high signal): | Category | Evidence in `components/**` | Why this violates the contract | Required remediation | |---|---|---|---| | Locale formatting in components | `toLocale*` in [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192), [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75), [`NotificationCenter.tsx`](apps/website/components/notifications/NotificationCenter.tsx:84) | Determinism/hydration risk; forbidden formatting path. | Ensure components consume deterministic ViewData strings/numbers produced by Presenters/Display Objects. | | Client-initiated write calls | `fetch('/api/auth/logout', { method: 'POST' })` in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | Violates Server Action-only write boundary ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)). | Replace with Server Action invoked via ``. | ### 5.3 Separation of concerns Answer: **No**; boundaries are blurred. Concrete evidence: 1. Templates import ViewModels and compute. - Import example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7) - Compute example: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81) 2. Templates exist under `app/**`. - Example: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1) 3. Services construct ViewModels. - Example: `return new RaceDetailViewModel(...)` in [`RaceService`](apps/website/lib/services/races/RaceService.ts:24) - Example: `return ...map(... => new TeamSummaryViewModel(...))` in [`TeamService`](apps/website/lib/services/teams/TeamService.ts:35) Remediation: - Server: PageQueries return Page DTO. - Client: Presenters/ViewModels produce ViewData. - Templates: ViewData-only, no compute. ### 5.4 DI usage Answer: **Not correct today**; server uses the singleton container. Confirmed violations: - Server route DI: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45) - Server PageQuery DI: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110) - Server helper enabling DI: [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:21) - Server service function DI: [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:9) - Dynamic import DI in PageQuery: [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43) Remediation: 1. Add guardrail: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61). 2. Refactor these call sites to manual wiring or request-scoped container. ### 5.5 Server Actions Answer: **Not fully enforced**; client-side POSTs exist. Confirmed violations: - Client POST logout in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) - Client POST logout in [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) - Client POST logout in [`sponsor/settings/page.tsx`](apps/website/app/sponsor/settings/page.tsx:178) Remediation: 1. Implement a Server Action for logout. 2. Replace all client-side POSTs with ``. 3. Add guardrail to forbid `fetch` write methods in client code. --- ## A) DI contract violations (server-side singleton container usage) | 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`](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 `` 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` 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` 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//actions.ts`: Server Actions for that route only (mutations, UX validation, redirect/revalidate). 2. `*PageClient.tsx` (or a client component) uses `` to invoke the action. 3. Services called by actions remain DTO-only and do not contain UI logic. This is the mechanism that keeps pages free from business logic and leaves no seams for drift. ### 9.5 ViewModels: allowed, but only behind the client boundary You chose: keep ViewModels, enforce client-only. Non-negotiable rules (to avoid gaps): 1. No server module imports from `apps/website/lib/view-models/**`. 2. No `apps/website/lib/services/**` imports from `apps/website/lib/view-models/**`. 3. A ViewModel instance is never passed into a Template. 4. A ViewModel instance is never serialized. Allowed flows: - Page DTO → Presenter → ViewData → Template - Page DTO → Presenter → ViewModel → Presenter → ViewData → Template This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels). --- ## High-signal file sets (pattern-based indicators) ### Templates importing ViewModels and or Display Objects (forbidden) - [`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)