diff --git a/apps/website/app/dashboard/DashboardPageClient.tsx b/apps/website/app/dashboard/DashboardPageClient.tsx new file mode 100644 index 000000000..b7e730f67 --- /dev/null +++ b/apps/website/app/dashboard/DashboardPageClient.tsx @@ -0,0 +1,95 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import type { DashboardViewData } from './DashboardViewData'; +import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; +import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; +import { DashboardTemplate } from '@/templates/DashboardTemplate'; + +interface DashboardPageClientProps { + initialViewData: DashboardViewData; + dto: DashboardOverviewViewModelData; +} + +/** + * Dashboard Page Client Component + * + * Two-phase render: + * 1. Initial SSR: Uses ViewData built directly from DTO (no ViewModel) + * 2. Post-hydration: Instantiates ViewModel and re-renders with enhanced data + */ +export function DashboardPageClient({ initialViewData, dto }: DashboardPageClientProps) { + const [viewData, setViewData] = useState(initialViewData); + + useEffect(() => { + // Phase 2: After hydration, instantiate ViewModel and enhance data + const viewModel = new DashboardOverviewViewModel(dto); + + const enhancedViewData: DashboardViewData = { + currentDriver: { + name: viewModel.currentDriverName, + avatarUrl: viewModel.currentDriverAvatarUrl, + country: viewModel.currentDriverCountry, + rating: viewModel.currentDriverRating, + rank: viewModel.currentDriverRank, + totalRaces: viewModel.currentDriverTotalRaces, + wins: viewModel.currentDriverWins, + podiums: viewModel.currentDriverPodiums, + consistency: viewModel.currentDriverConsistency, + }, + nextRace: viewModel.nextRace ? { + id: viewModel.nextRace.id, + track: viewModel.nextRace.track, + car: viewModel.nextRace.car, + scheduledAt: viewModel.nextRace.scheduledAt, + formattedDate: viewModel.nextRace.formattedDate, + formattedTime: viewModel.nextRace.formattedTime, + timeUntil: viewModel.nextRace.timeUntil, + isMyLeague: viewModel.nextRace.isMyLeague, + } : null, + upcomingRaces: viewModel.upcomingRaces.map((race) => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + formattedDate: race.formattedDate, + formattedTime: race.formattedTime, + timeUntil: race.timeUntil, + isMyLeague: race.isMyLeague, + })), + leagueStandings: viewModel.leagueStandings.map((standing) => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: standing.position, + points: standing.points, + totalDrivers: standing.totalDrivers, + })), + feedItems: viewModel.feedItems.map((item) => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: item.timestamp, + formattedTime: item.formattedTime, + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + })), + friends: viewModel.friends.map((friend) => ({ + id: friend.id, + name: friend.name, + avatarUrl: friend.avatarUrl, + country: friend.country, + })), + activeLeaguesCount: viewModel.activeLeaguesCount, + friendCount: viewModel.friendCount, + hasUpcomingRaces: viewModel.hasUpcomingRaces, + hasLeagueStandings: viewModel.hasLeagueStandings, + hasFeedItems: viewModel.hasFeedItems, + hasFriends: viewModel.hasFriends, + }; + + setViewData(enhancedViewData); + }, [dto]); + + return ; +} \ No newline at end of file diff --git a/apps/website/app/dashboard/DashboardViewData.ts b/apps/website/app/dashboard/DashboardViewData.ts new file mode 100644 index 000000000..3c699c684 --- /dev/null +++ b/apps/website/app/dashboard/DashboardViewData.ts @@ -0,0 +1,70 @@ +/** + * Dashboard ViewData + * + * SSR-safe data structure that can be built directly from DTO + * without ViewModel instantiation. Contains formatted values + * for display and ISO string timestamps for JSON serialization. + */ + +export interface DashboardViewData { + currentDriver: { + name: string; + avatarUrl: string; + country: string; + rating: string; + rank: string; + totalRaces: string; + wins: string; + podiums: string; + consistency: string; + }; + nextRace: { + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + formattedDate: string; + formattedTime: string; + timeUntil: string; + isMyLeague: boolean; + } | null; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + formattedDate: string; + formattedTime: string; + timeUntil: string; + isMyLeague: boolean; + }>; + leagueStandings: Array<{ + leagueId: string; + leagueName: string; + position: string; + points: string; + totalDrivers: string; + }>; + feedItems: Array<{ + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; // ISO string + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; + }>; + friends: Array<{ + id: string; + name: string; + avatarUrl: string; + country: string; + }>; + activeLeaguesCount: string; + friendCount: string; + hasUpcomingRaces: boolean; + hasLeagueStandings: boolean; + hasFeedItems: boolean; + hasFriends: boolean; +} \ No newline at end of file diff --git a/apps/website/app/dashboard/DashboardViewDataBuilder.ts b/apps/website/app/dashboard/DashboardViewDataBuilder.ts new file mode 100644 index 000000000..7125e111f --- /dev/null +++ b/apps/website/app/dashboard/DashboardViewDataBuilder.ts @@ -0,0 +1,88 @@ +import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; +import type { DashboardViewData } from './DashboardViewData'; +import { + formatDashboardDate, + formatRating, + formatRank, + formatConsistency, + formatRaceCount, + formatFriendCount, + formatLeaguePosition, + formatPoints, + formatTotalDrivers, +} from '@/lib/display-objects/DashboardDisplay'; + +/** + * Build DashboardViewData directly from ViewModelData + * Used for SSR phase - no ViewModel instantiation + */ +export function buildDashboardViewData(viewModelData: DashboardOverviewViewModelData): DashboardViewData { + return { + currentDriver: { + name: viewModelData.currentDriver?.name || '', + avatarUrl: viewModelData.currentDriver?.avatarUrl || '', + country: viewModelData.currentDriver?.country || '', + rating: viewModelData.currentDriver ? formatRating(viewModelData.currentDriver.rating) : '0.0', + rank: viewModelData.currentDriver ? formatRank(viewModelData.currentDriver.globalRank) : '0', + totalRaces: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.totalRaces) : '0', + wins: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.wins) : '0', + podiums: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.podiums) : '0', + consistency: viewModelData.currentDriver ? formatConsistency(viewModelData.currentDriver.consistency) : '0%', + }, + nextRace: viewModelData.nextRace ? (() => { + const dateInfo = formatDashboardDate(new Date(viewModelData.nextRace.scheduledAt)); + return { + id: viewModelData.nextRace.id, + track: viewModelData.nextRace.track, + car: viewModelData.nextRace.car, + scheduledAt: viewModelData.nextRace.scheduledAt, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + isMyLeague: viewModelData.nextRace.isMyLeague, + }; + })() : null, + upcomingRaces: viewModelData.upcomingRaces.map((race) => { + const dateInfo = formatDashboardDate(new Date(race.scheduledAt)); + return { + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + isMyLeague: race.isMyLeague, + }; + }), + leagueStandings: viewModelData.leagueStandingsSummaries.map((standing) => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: formatLeaguePosition(standing.position), + points: formatPoints(standing.points), + totalDrivers: formatTotalDrivers(standing.totalDrivers), + })), + feedItems: viewModelData.feedSummary.items.map((item) => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: item.timestamp, + formattedTime: formatDashboardDate(new Date(item.timestamp)).relative, + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + })), + friends: viewModelData.friends.map((friend) => ({ + id: friend.id, + name: friend.name, + avatarUrl: friend.avatarUrl, + country: friend.country, + })), + activeLeaguesCount: formatRaceCount(viewModelData.activeLeaguesCount), + friendCount: formatFriendCount(viewModelData.friends.length), + hasUpcomingRaces: viewModelData.upcomingRaces.length > 0, + hasLeagueStandings: viewModelData.leagueStandingsSummaries.length > 0, + hasFeedItems: viewModelData.feedSummary.items.length > 0, + hasFriends: viewModelData.friends.length > 0, + }; +} \ No newline at end of file diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 9467b8d46..42e5b8666 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,17 +1,31 @@ -import { notFound } from 'next/navigation'; -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { DashboardTemplate } from '@/templates/DashboardTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { DashboardService } from '@/lib/services/dashboard/DashboardService'; +import { notFound, redirect } from 'next/navigation'; +import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery'; +import { DashboardPageClient } from './DashboardPageClient'; +import { buildDashboardViewData } from './DashboardViewDataBuilder'; export default async function DashboardPage() { - const data = await PageDataFetcher.fetch( - DASHBOARD_SERVICE_TOKEN, - 'getDashboardOverview' - ); + const result = await DashboardPageQuery.execute(); - if (!data) notFound(); - - return ; + // Handle result based on status + switch (result.status) { + case 'ok': + const viewModelData = result.data; + + // Build SSR ViewData directly from ViewModelData + const ssrViewData = buildDashboardViewData(viewModelData); + + // Pass both ViewData (for SSR) and ViewModelData (for client enhancement) + return ; + + case 'notFound': + notFound(); + + case 'redirect': + redirect(result.destination); + + case 'error': + // For now, treat as notFound. Could also show error page + console.error('Dashboard error:', result.error); + notFound(); + } } \ No newline at end of file diff --git a/apps/website/components/dashboard/FeedItemRow.tsx b/apps/website/components/dashboard/FeedItemRow.tsx index 76f43910b..577f142e4 100644 --- a/apps/website/components/dashboard/FeedItemRow.tsx +++ b/apps/website/components/dashboard/FeedItemRow.tsx @@ -2,9 +2,19 @@ import { Activity, Trophy, Medal, UserPlus, Heart, Flag, Play } from 'lucide-rea import Button from '@/components/ui/Button'; import Link from 'next/link'; import { timeAgo } from '@/lib/utilities/time'; -import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; -function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) { +interface FeedItemData { + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} + +function FeedItemRow({ item }: { item: FeedItemData }) { const getActivityIcon = (type: string) => { if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; diff --git a/apps/website/components/feed/FeedItemCard.tsx b/apps/website/components/feed/FeedItemCard.tsx index c3ea866aa..915464d43 100644 --- a/apps/website/components/feed/FeedItemCard.tsx +++ b/apps/website/components/feed/FeedItemCard.tsx @@ -2,7 +2,17 @@ import { useEffect, useState } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Image from 'next/image'; -import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; + +interface FeedItemData { + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} function timeAgo(timestamp: Date | string): string { const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp; @@ -16,14 +26,14 @@ function timeAgo(timestamp: Date | string): string { return `${diffDays} d ago`; } -async function resolveActor(_item: DashboardFeedItemSummaryViewModel) { +async function resolveActor(_item: FeedItemData) { // Actor resolution is not wired through the API in this build. // Keep rendering deterministic and decoupled (no core repos). return null; } interface FeedItemCardProps { - item: DashboardFeedItemSummaryViewModel; + item: FeedItemData; } export default function FeedItemCard({ item }: FeedItemCardProps) { @@ -92,4 +102,4 @@ export default function FeedItemCard({ item }: FeedItemCardProps) { ); -} +} \ No newline at end of file diff --git a/apps/website/components/feed/FeedLayout.tsx b/apps/website/components/feed/FeedLayout.tsx index c6111561b..0c931edbd 100644 --- a/apps/website/components/feed/FeedLayout.tsx +++ b/apps/website/components/feed/FeedLayout.tsx @@ -1,9 +1,19 @@ import Card from '@/components/ui/Card'; -import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; import FeedList from '@/components/feed/FeedList'; import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar'; import LatestResultsSidebar from '@/components/races/LatestResultsSidebar'; +interface FeedItemData { + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} + type FeedUpcomingRace = { id: string; track: string; @@ -20,7 +30,7 @@ type FeedLatestResult = { }; interface FeedLayoutProps { - feedItems: DashboardFeedItemSummaryViewModel[]; + feedItems: FeedItemData[]; upcomingRaces: FeedUpcomingRace[]; latestResults: FeedLatestResult[]; } @@ -53,4 +63,4 @@ export default function FeedLayout({ ); -} +} \ No newline at end of file diff --git a/apps/website/components/feed/FeedList.tsx b/apps/website/components/feed/FeedList.tsx index aba746c04..4a0564bd0 100644 --- a/apps/website/components/feed/FeedList.tsx +++ b/apps/website/components/feed/FeedList.tsx @@ -1,9 +1,19 @@ import FeedEmptyState from '@/components/feed/FeedEmptyState'; import FeedItemCard from '@/components/feed/FeedItemCard'; -import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; + +interface FeedItemData { + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} interface FeedListProps { - items: DashboardFeedItemSummaryViewModel[]; + items: FeedItemData[]; } export default function FeedList({ items }: FeedListProps) { @@ -18,4 +28,4 @@ export default function FeedList({ items }: FeedListProps) { ))} ); -} +} \ No newline at end of file diff --git a/apps/website/lib/display-objects/DashboardDisplay.ts b/apps/website/lib/display-objects/DashboardDisplay.ts new file mode 100644 index 000000000..e4fae1a81 --- /dev/null +++ b/apps/website/lib/display-objects/DashboardDisplay.ts @@ -0,0 +1,138 @@ +/** + * Dashboard Display Objects + * + * Deterministic formatting for dashboard data without Intl.* or toLocale* + */ + +export interface DashboardStatDisplayData { + icon: string; + color: string; + label: string; +} + +export interface DashboardDateDisplayData { + date: string; + time: string; + relative: string; +} + +/** + * Stat card display configurations + */ +export const dashboardStatDisplay = { + wins: { + icon: 'Trophy', + color: 'bg-performance-green/20 text-performance-green', + label: 'Wins', + }, + podiums: { + icon: 'Medal', + color: 'bg-warning-amber/20 text-warning-amber', + label: 'Podiums', + }, + consistency: { + icon: 'Target', + color: 'bg-primary-blue/20 text-primary-blue', + label: 'Consistency', + }, + activeLeagues: { + icon: 'Users', + color: 'bg-purple-500/20 text-purple-400', + label: 'Active Leagues', + }, +} as const; + +/** + * Format date for display (deterministic, no Intl) + */ +export function formatDashboardDate(date: Date): DashboardDateDisplayData { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + const dayName = days[date.getDay()]; + const month = months[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + // Calculate relative time (deterministic) + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffHours / 24); + + let relative: string; + if (diffHours < 0) { + relative = 'Past'; + } else if (diffHours === 0) { + relative = 'Now'; + } else if (diffHours < 24) { + relative = `${diffHours}h`; + } else { + relative = `${diffDays}d`; + } + + return { + date: `${dayName}, ${month} ${day}, ${year}`, + time: `${hours}:${minutes}`, + relative, + }; +} + +/** + * Format rating for display + */ +export function formatRating(rating: number): string { + return rating.toFixed(1); +} + +/** + * Format rank for display + */ +export function formatRank(rank: number): string { + return rank.toString(); +} + +/** + * Format consistency percentage + */ +export function formatConsistency(consistency: number): string { + return `${consistency}%`; +} + +/** + * Format race count + */ +export function formatRaceCount(count: number): string { + return count.toString(); +} + +/** + * Format friend count + */ +export function formatFriendCount(count: number): string { + return count.toString(); +} + +/** + * Format league position + */ +export function formatLeaguePosition(position: number): string { + return `#${position}`; +} + +/** + * Format points + */ +export function formatPoints(points: number): string { + return points.toString(); +} + +/** + * Format total drivers + */ +export function formatTotalDrivers(total: number): string { + return total.toString(); +} \ No newline at end of file diff --git a/apps/website/lib/display-objects/ProfileDisplay.ts b/apps/website/lib/display-objects/ProfileDisplay.ts new file mode 100644 index 000000000..1c2209203 --- /dev/null +++ b/apps/website/lib/display-objects/ProfileDisplay.ts @@ -0,0 +1,261 @@ +/** + * Profile Display Objects + * + * Deterministic formatting for profile data. + * NO Intl.*, NO Date.toLocale*, NO dynamic formatting. + */ + +// ============================================================================ +// COUNTRY FLAG DISPLAY +// ============================================================================ + +export interface CountryFlagDisplayData { + flag: string; + label: string; +} + +export const countryFlagDisplay: Record = { + // Common country codes - add as needed + US: { flag: '๐Ÿ‡บ๐Ÿ‡ธ', label: 'United States' }, + GB: { flag: '๐Ÿ‡ฌ๐Ÿ‡ง', label: 'United Kingdom' }, + DE: { flag: '๐Ÿ‡ฉ๐Ÿ‡ช', label: 'Germany' }, + FR: { flag: '๐Ÿ‡ซ๐Ÿ‡ท', label: 'France' }, + IT: { flag: '๐Ÿ‡ฎ๐Ÿ‡น', label: 'Italy' }, + ES: { flag: '๐Ÿ‡ช๐Ÿ‡ธ', label: 'Spain' }, + JP: { flag: '๐Ÿ‡ฏ๐Ÿ‡ต', label: 'Japan' }, + AU: { flag: '๐Ÿ‡ฆ๐Ÿ‡บ', label: 'Australia' }, + CA: { flag: '๐Ÿ‡จ๐Ÿ‡ฆ', label: 'Canada' }, + BR: { flag: '๐Ÿ‡ง๐Ÿ‡ท', label: 'Brazil' }, + // Fallback for unknown codes + DEFAULT: { flag: '๐Ÿ', label: 'Unknown' }, +} as const; + +export function getCountryFlagDisplay(countryCode: string): CountryFlagDisplayData { + const code = countryCode.toUpperCase(); + return countryFlagDisplay[code] || countryFlagDisplay.DEFAULT; +} + +// ============================================================================ +// ACHIEVEMENT RARITY DISPLAY +// ============================================================================ + +export interface AchievementRarityDisplayData { + text: string; + badgeClasses: string; + borderClasses: string; +} + +export const achievementRarityDisplay: Record = { + common: { + text: 'Common', + badgeClasses: 'bg-gray-400/10 text-gray-400', + borderClasses: 'border-gray-400/30', + }, + rare: { + text: 'Rare', + badgeClasses: 'bg-primary-blue/10 text-primary-blue', + borderClasses: 'border-primary-blue/30', + }, + epic: { + text: 'Epic', + badgeClasses: 'bg-purple-400/10 text-purple-400', + borderClasses: 'border-purple-400/30', + }, + legendary: { + text: 'Legendary', + badgeClasses: 'bg-yellow-400/10 text-yellow-400', + borderClasses: 'border-yellow-400/30', + }, +} as const; + +export function getAchievementRarityDisplay(rarity: string): AchievementRarityDisplayData { + return achievementRarityDisplay[rarity] || achievementRarityDisplay.common; +} + +// ============================================================================ +// ACHIEVEMENT ICON DISPLAY +// ============================================================================ + +export type AchievementIconType = 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; + +export interface AchievementIconDisplayData { + name: string; + // Icon component will be resolved in UI layer +} + +export const achievementIconDisplay: Record = { + trophy: { name: 'Trophy' }, + medal: { name: 'Medal' }, + star: { name: 'Star' }, + crown: { name: 'Crown' }, + target: { name: 'Target' }, + zap: { name: 'Zap' }, +} as const; + +export function getAchievementIconDisplay(icon: string): AchievementIconDisplayData { + return achievementIconDisplay[icon as AchievementIconType] || achievementIconDisplay.trophy; +} + +// ============================================================================ +// SOCIAL PLATFORM DISPLAY +// ============================================================================ + +export interface SocialPlatformDisplayData { + name: string; + hoverClasses: string; +} + +export const socialPlatformDisplay: Record = { + twitter: { + name: 'Twitter', + hoverClasses: 'hover:text-sky-400 hover:bg-sky-400/10', + }, + youtube: { + name: 'YouTube', + hoverClasses: 'hover:text-red-500 hover:bg-red-500/10', + }, + twitch: { + name: 'Twitch', + hoverClasses: 'hover:text-purple-400 hover:bg-purple-400/10', + }, + discord: { + name: 'Discord', + hoverClasses: 'hover:text-indigo-400 hover:bg-indigo-400/10', + }, +} as const; + +export function getSocialPlatformDisplay(platform: string): SocialPlatformDisplayData { + return socialPlatformDisplay[platform] || socialPlatformDisplay.discord; +} + +// ============================================================================ +// DATE FORMATTING (DETERMINISTIC) +// ============================================================================ + +/** + * Format date string to "Month Year" format + * Input: ISO date string (e.g., "2024-01-15T10:30:00Z") + * Output: "Jan 2024" + */ +export function formatMonthYear(dateString: string): string { + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + return `${month} ${year}`; +} + +/** + * Format date string to "Month Day, Year" format + * Input: ISO date string + * Output: "Jan 15, 2024" + */ +export function formatMonthDayYear(dateString: string): string { + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getUTCMonth()]; + const day = date.getUTCDate(); + const year = date.getUTCFullYear(); + return `${month} ${day}, ${year}`; +} + +// ============================================================================ +// STATISTICS FORMATTING +// ============================================================================ + +export interface StatDisplayData { + value: string; + label: string; +} + +/** + * Format percentage with 1 decimal place + * Input: 0.1234 + * Output: "12.3%" + */ +export function formatPercentage(value: number | null): string { + if (value === null || value === undefined) return '0.0%'; + return `${(value * 100).toFixed(1)}%`; +} + +/** + * Format finish position + * Input: 1 + * Output: "P1" + */ +export function formatFinishPosition(position: number | null): string { + if (position === null || position === undefined) return 'P-'; + return `P${position}`; +} + +/** + * Format average finish with 1 decimal place + * Input: 3.456 + * Output: "P3.5" + */ +export function formatAvgFinish(avg: number | null): string { + if (avg === null || avg === undefined) return 'P-'; + return `P${avg.toFixed(1)}`; +} + +/** + * Format rating (whole number) + * Input: 1234.56 + * Output: "1235" + */ +export function formatRating(rating: number | null): string { + if (rating === null || rating === undefined) return '0'; + return Math.round(rating).toString(); +} + +/** + * Format consistency percentage + * Input: 87.5 + * Output: "88%" + */ +export function formatConsistency(consistency: number | null): string { + if (consistency === null || consistency === undefined) return '0%'; + return `${Math.round(consistency)}%`; +} + +/** + * Format percentile + * Input: 15.5 + * Output: "Top 16%" + */ +export function formatPercentile(percentile: number | null): string { + if (percentile === null || percentile === undefined) return 'Top -%'; + return `Top ${Math.round(percentile)}%`; +} + +// ============================================================================ +// TEAM ROLE DISPLAY +// ============================================================================ + +export interface TeamRoleDisplayData { + text: string; + badgeClasses: string; +} + +export const teamRoleDisplay: Record = { + owner: { + text: 'Owner', + badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', + }, + admin: { + text: 'Admin', + badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + }, + steward: { + text: 'Steward', + badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + }, + member: { + text: 'Member', + badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30', + }, +} as const; + +export function getTeamRoleDisplay(role: string): TeamRoleDisplayData { + return teamRoleDisplay[role] || teamRoleDisplay.member; +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/DashboardPageQuery.ts b/apps/website/lib/page-queries/DashboardPageQuery.ts new file mode 100644 index 000000000..c313bf386 --- /dev/null +++ b/apps/website/lib/page-queries/DashboardPageQuery.ts @@ -0,0 +1,140 @@ +import { notFound, redirect } from 'next/navigation'; +import { ContainerManager } from '@/lib/di/container'; +import { DASHBOARD_API_CLIENT_TOKEN } from '@/lib/di/tokens'; +import type { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; +import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO'; +import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; + +/** + * PageQueryResult discriminated union for SSR page queries + */ +export type PageQueryResult = + | { status: 'ok'; data: TData } + | { status: 'notFound' } + | { status: 'redirect'; destination: string } + | { status: 'error'; error: Error }; + +/** + * Transform DashboardOverviewDTO to DashboardOverviewViewModelData + * Converts string dates to ISO strings for JSON serialization + */ +function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOverviewViewModelData { + return { + currentDriver: dto.currentDriver ? { + id: dto.currentDriver.id, + name: dto.currentDriver.name, + avatarUrl: dto.currentDriver.avatarUrl || '', + country: dto.currentDriver.country, + totalRaces: dto.currentDriver.totalRaces, + wins: dto.currentDriver.wins, + podiums: dto.currentDriver.podiums, + rating: dto.currentDriver.rating ?? 0, + globalRank: dto.currentDriver.globalRank ?? 0, + consistency: dto.currentDriver.consistency ?? 0, + } : undefined, + myUpcomingRaces: dto.myUpcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + upcomingRaces: dto.upcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + activeLeaguesCount: dto.activeLeaguesCount, + nextRace: dto.nextRace ? { + id: dto.nextRace.id, + track: dto.nextRace.track, + car: dto.nextRace.car, + scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(), + status: dto.nextRace.status, + isMyLeague: dto.nextRace.isMyLeague, + } : undefined, + recentResults: dto.recentResults.map(result => ({ + id: result.raceId, + track: result.raceName, + car: '', // Not in DTO, will need to handle + position: result.position, + date: new Date(result.finishedAt).toISOString(), + })), + leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: standing.position, + points: standing.points, + totalDrivers: standing.totalDrivers, + })), + feedSummary: { + notificationCount: dto.feedSummary.notificationCount, + items: dto.feedSummary.items.map(item => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: new Date(item.timestamp).toISOString(), + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + })), + }, + friends: dto.friends.map(friend => ({ + id: friend.id, + name: friend.name, + avatarUrl: friend.avatarUrl || '', + country: friend.country, + })), + }; +} + +/** + * Dashboard page query that returns transformed ViewModelData + * Returns a discriminated union instead of nullable data + */ +export class DashboardPageQuery { + static async execute(): Promise> { + try { + const container = ContainerManager.getInstance().getContainer(); + const apiClient = container.get(DASHBOARD_API_CLIENT_TOKEN); + + const dto = await apiClient.getDashboardOverview(); + + if (!dto) { + return { status: 'notFound' }; + } + + const viewModelData = transformDtoToViewModelData(dto); + return { status: 'ok', data: viewModelData }; + } catch (error) { + // Handle specific error types + if (error instanceof Error) { + // Check if it's a not found error + if (error.message.includes('not found') || (error as any).statusCode === 404) { + return { status: 'notFound' }; + } + + // Check if it's a redirect error + if (error.message.includes('redirect') || (error as any).statusCode === 302) { + return { status: 'redirect', destination: '/' }; + } + + return { status: 'error', error }; + } + + return { status: 'error', error: new Error(String(error)) }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/ProfilePageQuery.ts b/apps/website/lib/page-queries/ProfilePageQuery.ts new file mode 100644 index 000000000..7a5671134 --- /dev/null +++ b/apps/website/lib/page-queries/ProfilePageQuery.ts @@ -0,0 +1,114 @@ +import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; +import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { DriverService } from '@/lib/services/drivers/DriverService'; +import type { DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type PageQueryResult = + | { status: 'ok'; dto: DriverProfileViewModelData } + | { status: 'notFound' } + | { status: 'redirect'; to: string } + | { status: 'error'; errorId: string }; + +// ============================================================================ +// SERVER QUERY CLASS +// ============================================================================ + +/** + * ProfilePageQuery + * + * Server-side data fetcher for the profile page. + * Returns a discriminated union with all possible page states. + * Ensures JSON-serializable DTO with no null leakage. + */ +export class ProfilePageQuery { + /** + * Execute the profile page query + * + * @param driverId - The driver ID to fetch profile for + * @returns PageQueryResult with discriminated union of states + */ + static async execute(driverId: string | null): Promise { + // Handle missing driver ID + if (!driverId) { + return { status: 'notFound' }; + } + + try { + // Fetch using PageDataFetcher to avoid direct DI in page + const driverService = await PageDataFetcher.fetchManual(async () => { + const container = (await import('@/lib/di/container')).ContainerManager.getInstance().getContainer(); + return container.get(DRIVER_SERVICE_TOKEN); + }); + + if (!driverService) { + return { status: 'error', errorId: 'SERVICE_UNAVAILABLE' }; + } + + const viewModel = await driverService.getDriverProfile(driverId); + + // Convert to DTO and ensure JSON-serializable + const dto = this.toSerializableDTO(viewModel.toDTO()); + + if (!dto.currentDriver) { + return { status: 'notFound' }; + } + + return { status: 'ok', dto }; + + } catch (error) { + console.error('ProfilePageQuery failed:', error); + return { status: 'error', errorId: 'FETCH_FAILED' }; + } + } + + /** + * Convert DTO to ensure JSON-serializability + * - Dates become ISO strings + * - Undefined becomes null + * - No Date objects remain + */ + private static toSerializableDTO(dto: DriverProfileViewModelData): DriverProfileViewModelData { + return { + currentDriver: dto.currentDriver ? { + ...dto.currentDriver, + joinedAt: dto.currentDriver.joinedAt, // Already ISO string + } : null, + stats: dto.stats ? { + ...dto.stats, + // Ensure all nullable numbers are properly handled + avgFinish: dto.stats.avgFinish ?? null, + bestFinish: dto.stats.bestFinish ?? null, + worstFinish: dto.stats.worstFinish ?? null, + finishRate: dto.stats.finishRate ?? null, + winRate: dto.stats.winRate ?? null, + podiumRate: dto.stats.podiumRate ?? null, + percentile: dto.stats.percentile ?? null, + rating: dto.stats.rating ?? null, + consistency: dto.stats.consistency ?? null, + overallRank: dto.stats.overallRank ?? null, + } : null, + finishDistribution: dto.finishDistribution ? { ...dto.finishDistribution } : null, + teamMemberships: dto.teamMemberships.map(m => ({ + ...m, + joinedAt: m.joinedAt, // Already ISO string + })), + socialSummary: { + friendsCount: dto.socialSummary.friendsCount, + friends: dto.socialSummary.friends.map(f => ({ + ...f, + })), + }, + extendedProfile: dto.extendedProfile ? { + ...dto.extendedProfile, + achievements: dto.extendedProfile.achievements.map(a => ({ + ...a, + earnedAt: a.earnedAt, // Already ISO string + })), + } : null, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/services/dashboard/DashboardService.ts b/apps/website/lib/services/dashboard/DashboardService.ts index 73a83754a..ea21f3727 100644 --- a/apps/website/lib/services/dashboard/DashboardService.ts +++ b/apps/website/lib/services/dashboard/DashboardService.ts @@ -1,5 +1,7 @@ import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel'; import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient'; +import type { DashboardOverviewDTO } from '../../types/generated/DashboardOverviewDTO'; +import type { DashboardOverviewViewModelData } from '../../view-models/DashboardOverviewViewModelData'; /** * Dashboard Service @@ -14,9 +16,98 @@ export class DashboardService { /** * Get dashboard overview data with view model transformation + * Returns the ViewModel for backward compatibility */ async getDashboardOverview(): Promise { const dto = await this.apiClient.getDashboardOverview(); - return new DashboardOverviewViewModel(dto); + // Convert DTO to ViewModelData format for the ViewModel + const viewModelData: DashboardOverviewViewModelData = { + currentDriver: dto.currentDriver ? { + id: dto.currentDriver.id, + name: dto.currentDriver.name, + avatarUrl: dto.currentDriver.avatarUrl || '', + country: dto.currentDriver.country, + totalRaces: dto.currentDriver.totalRaces, + wins: dto.currentDriver.wins, + podiums: dto.currentDriver.podiums, + rating: dto.currentDriver.rating ?? 0, + globalRank: dto.currentDriver.globalRank ?? 0, + consistency: dto.currentDriver.consistency ?? 0, + } : undefined, + myUpcomingRaces: dto.myUpcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + upcomingRaces: dto.upcomingRaces.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: new Date(race.scheduledAt).toISOString(), + status: race.status, + isMyLeague: race.isMyLeague, + })), + activeLeaguesCount: dto.activeLeaguesCount, + nextRace: dto.nextRace ? { + id: dto.nextRace.id, + track: dto.nextRace.track, + car: dto.nextRace.car, + scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(), + status: dto.nextRace.status, + isMyLeague: dto.nextRace.isMyLeague, + } : undefined, + recentResults: dto.recentResults.map(result => ({ + id: result.raceId, + track: result.raceName, + car: '', + position: result.position, + date: new Date(result.finishedAt).toISOString(), + })), + leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: standing.position, + points: standing.points, + totalDrivers: standing.totalDrivers, + })), + feedSummary: { + notificationCount: dto.feedSummary.notificationCount, + items: dto.feedSummary.items.map(item => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: new Date(item.timestamp).toISOString(), + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + })), + }, + friends: dto.friends.map(friend => ({ + id: friend.id, + name: friend.name, + avatarUrl: friend.avatarUrl || '', + country: friend.country, + })), + }; + + return new DashboardOverviewViewModel(viewModelData); + } + + /** + * Get raw DTO for page queries + */ + async getDashboardOverviewDTO(): Promise { + return await this.apiClient.getDashboardOverview(); } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DashboardOverviewViewModel.ts b/apps/website/lib/view-models/DashboardOverviewViewModel.ts index ab2a07101..e422c0f33 100644 --- a/apps/website/lib/view-models/DashboardOverviewViewModel.ts +++ b/apps/website/lib/view-models/DashboardOverviewViewModel.ts @@ -1,217 +1,198 @@ -import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO'; -import type { DashboardDriverSummaryDTO } from '@/lib/types/generated/DashboardDriverSummaryDTO'; -import type { DashboardRaceSummaryDTO } from '@/lib/types/generated/DashboardRaceSummaryDTO'; -import type { DashboardLeagueStandingSummaryDTO } from '@/lib/types/generated/DashboardLeagueStandingSummaryDTO'; -import type { DashboardFeedItemSummaryDTO } from '@/lib/types/generated/DashboardFeedItemSummaryDTO'; -import type { DashboardFriendSummaryDTO } from '@/lib/types/generated/DashboardFriendSummaryDTO'; - -export class DashboardDriverSummaryViewModel { - constructor(private readonly dto: DashboardDriverSummaryDTO) {} - - get id(): string { - return this.dto.id; - } - - get name(): string { - return this.dto.name; - } - - get avatarUrl(): string { - return this.dto.avatarUrl || ''; - } - - get country(): string { - return this.dto.country; - } - - get totalRaces(): number { - return this.dto.totalRaces; - } - - get wins(): number { - return this.dto.wins; - } - - get podiums(): number { - return this.dto.podiums; - } - - get rating(): number { - return this.dto.rating ?? 0; - } - - get globalRank(): number { - return this.dto.globalRank ?? 0; - } - - get consistency(): number { - return this.dto.consistency ?? 0; - } -} - -export class DashboardRaceSummaryViewModel { - constructor(private readonly dto: DashboardRaceSummaryDTO) {} - - get id(): string { - return this.dto.id; - } - - get leagueId(): string { - return (this.dto as any).leagueId ?? ''; - } - - get leagueName(): string { - return (this.dto as any).leagueName ?? ''; - } - - get track(): string { - return this.dto.track; - } - - get car(): string { - return this.dto.car; - } - - get scheduledAt(): Date { - return new Date(this.dto.scheduledAt); - } - - get status(): string { - return this.dto.status; - } - - get isMyLeague(): boolean { - return this.dto.isMyLeague; - } -} - -export class DashboardLeagueStandingSummaryViewModel { - constructor(private readonly dto: DashboardLeagueStandingSummaryDTO) {} - - get leagueId(): string { - return this.dto.leagueId; - } - - get leagueName(): string { - return this.dto.leagueName; - } - - get position(): number { - return this.dto.position; - } - - get points(): number { - return this.dto.points; - } - - get totalDrivers(): number { - return this.dto.totalDrivers; - } -} - -export class DashboardFeedItemSummaryViewModel { - constructor(private readonly dto: DashboardFeedItemSummaryDTO) {} - - get id(): string { - return this.dto.id; - } - - get type(): string { - return this.dto.type; - } - - get headline(): string { - return this.dto.headline; - } - - get body(): string | undefined { - return this.dto.body; - } - - get timestamp(): Date { - return new Date(this.dto.timestamp); - } - - get ctaHref(): string | undefined { - return this.dto.ctaHref; - } - - get ctaLabel(): string | undefined { - return this.dto.ctaLabel; - } -} - -export class DashboardFriendSummaryViewModel { - constructor(private readonly dto: DashboardFriendSummaryDTO) {} - - get id(): string { - return this.dto.id; - } - - get name(): string { - return this.dto.name; - } - - get avatarUrl(): string { - return this.dto.avatarUrl || ''; - } - - get country(): string { - return this.dto.country; - } -} +import type { DashboardOverviewViewModelData } from './DashboardOverviewViewModelData'; +import { + dashboardStatDisplay, + formatDashboardDate, + formatRating, + formatRank, + formatConsistency, + formatRaceCount, + formatFriendCount, + formatLeaguePosition, + formatPoints, + formatTotalDrivers, +} from '@/lib/display-objects/DashboardDisplay'; +/** + * Dashboard Overview ViewModel + * + * Clean class that accepts DTO only and exposes derived values. + * This is client-only and instantiated after hydration. + */ export class DashboardOverviewViewModel { - constructor(private readonly dto: DashboardOverviewDTO) {} + constructor(private readonly dto: DashboardOverviewViewModelData) {} - get currentDriver(): DashboardDriverSummaryViewModel { - // DTO uses optional property; enforce a consistent object for the UI - return new DashboardDriverSummaryViewModel( - (this.dto as any).currentDriver ?? { - id: '', - name: '', - country: '', - avatarUrl: '', - totalRaces: 0, - wins: 0, - podiums: 0, - }, - ); + // Current Driver - Derived Values + get currentDriverName(): string { + return this.dto.currentDriver?.name || ''; } - get nextRace(): DashboardRaceSummaryViewModel | null { - const nextRace = (this.dto as any).nextRace; - return nextRace ? new DashboardRaceSummaryViewModel(nextRace) : null; + get currentDriverAvatarUrl(): string { + return this.dto.currentDriver?.avatarUrl || ''; } - get upcomingRaces(): DashboardRaceSummaryViewModel[] { - const upcomingRaces = (this.dto as any).upcomingRaces ?? []; - return upcomingRaces.map((r: any) => new DashboardRaceSummaryViewModel(r)); + get currentDriverCountry(): string { + return this.dto.currentDriver?.country || ''; } - get leagueStandings(): DashboardLeagueStandingSummaryViewModel[] { - const leagueStandings = (this.dto as any).leagueStandingsSummaries ?? (this.dto as any).leagueStandings ?? []; - return leagueStandings.map((s: any) => new DashboardLeagueStandingSummaryViewModel(s)); + get currentDriverRating(): string { + return this.dto.currentDriver ? formatRating(this.dto.currentDriver.rating) : '0.0'; } - get feedItems(): DashboardFeedItemSummaryViewModel[] { - const feedItems = (this.dto as any).feedSummary?.items ?? (this.dto as any).feedItems ?? []; - return feedItems.map((i: any) => new DashboardFeedItemSummaryViewModel(i)); + get currentDriverRank(): string { + return this.dto.currentDriver ? formatRank(this.dto.currentDriver.globalRank) : '0'; } - get friends(): DashboardFriendSummaryViewModel[] { - const friends = (this.dto as any).friends ?? []; - return friends.map((f: any) => new DashboardFriendSummaryViewModel(f)); + get currentDriverTotalRaces(): string { + return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.totalRaces) : '0'; } - get activeLeaguesCount(): number { - return (this.dto as any).activeLeaguesCount ?? 0; + get currentDriverWins(): string { + return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.wins) : '0'; } -} -export { - DashboardDriverSummaryViewModel as DriverViewModel, - DashboardRaceSummaryViewModel as RaceViewModel, - DashboardLeagueStandingSummaryViewModel as LeagueStandingViewModel, - DashboardFriendSummaryViewModel as FriendViewModel, -}; + get currentDriverPodiums(): string { + return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.podiums) : '0'; + } + + get currentDriverConsistency(): string { + return this.dto.currentDriver ? formatConsistency(this.dto.currentDriver.consistency) : '0%'; + } + + // Next Race - Derived Values + get nextRace(): { + id: string; + track: string; + car: string; + scheduledAt: string; + status: string; + isMyLeague: boolean; + formattedDate: string; + formattedTime: string; + timeUntil: string; + } | null { + if (!this.dto.nextRace) return null; + + const dateInfo = formatDashboardDate(new Date(this.dto.nextRace.scheduledAt)); + + return { + id: this.dto.nextRace.id, + track: this.dto.nextRace.track, + car: this.dto.nextRace.car, + scheduledAt: this.dto.nextRace.scheduledAt, + status: this.dto.nextRace.status, + isMyLeague: this.dto.nextRace.isMyLeague, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + }; + } + + // Upcoming Races - Derived Values + get upcomingRaces(): Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; + status: string; + isMyLeague: boolean; + formattedDate: string; + formattedTime: string; + timeUntil: string; + }> { + return this.dto.upcomingRaces.map((race) => { + const dateInfo = formatDashboardDate(new Date(race.scheduledAt)); + return { + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + status: race.status, + isMyLeague: race.isMyLeague, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + }; + }); + } + + // League Standings - Derived Values + get leagueStandings(): Array<{ + leagueId: string; + leagueName: string; + position: string; + points: string; + totalDrivers: string; + }> { + return this.dto.leagueStandingsSummaries.map((standing) => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: formatLeaguePosition(standing.position), + points: formatPoints(standing.points), + totalDrivers: formatTotalDrivers(standing.totalDrivers), + })); + } + + // Feed Items - Derived Values + get feedItems(): Array<{ + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + ctaHref?: string; + ctaLabel?: string; + formattedTime: string; + }> { + return this.dto.feedSummary.items.map((item) => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: item.timestamp, + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + formattedTime: formatDashboardDate(new Date(item.timestamp)).relative, + })); + } + + // Friends - Derived Values + get friends(): Array<{ + id: string; + name: string; + avatarUrl: string; + country: string; + }> { + // No additional formatting needed for friends + return this.dto.friends; + } + + // Active Leagues Count + get activeLeaguesCount(): string { + return formatRaceCount(this.dto.activeLeaguesCount); + } + + // Convenience getters for display + get hasNextRace(): boolean { + return this.dto.nextRace !== undefined; + } + + get hasUpcomingRaces(): boolean { + return this.dto.upcomingRaces.length > 0; + } + + get hasLeagueStandings(): boolean { + return this.dto.leagueStandingsSummaries.length > 0; + } + + get hasFeedItems(): boolean { + return this.dto.feedSummary.items.length > 0; + } + + get hasFriends(): boolean { + return this.dto.friends.length > 0; + } + + get friendCount(): string { + return formatFriendCount(this.dto.friends.length); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DashboardOverviewViewModelData.ts b/apps/website/lib/view-models/DashboardOverviewViewModelData.ts new file mode 100644 index 000000000..52ad81847 --- /dev/null +++ b/apps/website/lib/view-models/DashboardOverviewViewModelData.ts @@ -0,0 +1,84 @@ +/** + * Dashboard Page DTO + * This is the data transfer object that gets passed from server to client + * Contains ISO string timestamps for JSON serialization + */ +export interface DashboardOverviewViewModelData { + currentDriver?: { + id: string; + name: string; + avatarUrl: string; + country: string; + totalRaces: number; + wins: number; + podiums: number; + rating: number; + globalRank: number; + consistency: number; + }; + myUpcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + otherUpcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + activeLeaguesCount: number; + nextRace?: { + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }; + recentResults: Array<{ + id: string; + track: string; + car: string; + position: number; + date: string; // ISO string + }>; + leagueStandingsSummaries: Array<{ + leagueId: string; + leagueName: string; + position: number; + points: number; + totalDrivers: number; + }>; + feedSummary: { + notificationCount: number; + items: Array<{ + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; // ISO string + ctaHref?: string; + ctaLabel?: string; + }>; + }; + friends: Array<{ + id: string; + name: string; + avatarUrl: string; + country: string; + }>; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/index.ts b/apps/website/lib/view-models/index.ts index 3183675ac..6c4b215b7 100644 --- a/apps/website/lib/view-models/index.ts +++ b/apps/website/lib/view-models/index.ts @@ -10,11 +10,6 @@ export * from './CreateLeagueViewModel'; export * from './CreateTeamViewModel'; export { DashboardOverviewViewModel, - DashboardDriverSummaryViewModel, - DashboardRaceSummaryViewModel, - DashboardLeagueStandingSummaryViewModel, - DashboardFeedItemSummaryViewModel, - DashboardFriendSummaryViewModel, } from './DashboardOverviewViewModel'; export * from './DeleteMediaViewModel'; export * from './DriverLeaderboardItemViewModel'; diff --git a/apps/website/templates/DashboardTemplate.tsx b/apps/website/templates/DashboardTemplate.tsx index 872b25cae..e93c87e5f 100644 --- a/apps/website/templates/DashboardTemplate.tsx +++ b/apps/website/templates/DashboardTemplate.tsx @@ -27,12 +27,73 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { getCountryFlag } from '@/lib/utilities/country'; -import { getGreeting, timeUntil } from '@/lib/utilities/time'; +import { getGreeting } from '@/lib/utilities/time'; -import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; +interface DashboardViewData { + currentDriver: { + name: string; + avatarUrl: string; + country: string; + rating: string; + rank: string; + totalRaces: string; + wins: string; + podiums: string; + consistency: string; + }; + nextRace: { + id: string; + track: string; + car: string; + scheduledAt: string; + formattedDate: string; + formattedTime: string; + timeUntil: string; + isMyLeague: boolean; + } | null; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; + formattedDate: string; + formattedTime: string; + timeUntil: string; + isMyLeague: boolean; + }>; + leagueStandings: Array<{ + leagueId: string; + leagueName: string; + position: string; + points: string; + totalDrivers: string; + }>; + feedItems: Array<{ + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; + }>; + friends: Array<{ + id: string; + name: string; + avatarUrl: string; + country: string; + }>; + activeLeaguesCount: string; + friendCount: string; + hasUpcomingRaces: boolean; + hasLeagueStandings: boolean; + hasFeedItems: boolean; + hasFriends: boolean; +} interface DashboardTemplateProps { - data: DashboardOverviewViewModel; + data: DashboardViewData; } export function DashboardTemplate({ data }: DashboardTemplateProps) { @@ -44,7 +105,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) { const friends = data.friends; const activeLeaguesCount = data.activeLeaguesCount; - const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver; + const { totalRaces, wins, podiums, rating, rank, consistency } = currentDriver; return (
@@ -89,7 +150,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) {
- #{globalRank} + #{rank}
{totalRaces} races completed @@ -129,61 +190,54 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) { {/* Left Column - Main Content */}
{/* Next Race Card */} - {nextRace && ( - -
-
-
-
- - Next Race -
- {nextRace.isMyLeague && ( - - Your League - - )} -
- -
-
-

{nextRace.track}

-

{nextRace.car}

-
- - - {nextRace.scheduledAt.toLocaleDateString('en-US', { - weekday: 'long', - month: 'short', - day: 'numeric', - })} - - - - {nextRace.scheduledAt.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - })} - -
-
- -
-
-

Starts in

-

{timeUntil(nextRace.scheduledAt)}

-
- - - -
-
-
- - )} + {nextRace && ( + +
+
+
+
+ + Next Race +
+ {nextRace.isMyLeague && ( + + Your League + + )} +
+ +
+
+

{nextRace.track}

+

{nextRace.car}

+
+ + + {nextRace.formattedDate} + + + + {nextRace.formattedTime} + +
+
+ +
+
+

Starts in

+

{nextRace.timeUntil}

+
+ + + +
+
+
+ + )} {/* League Standings Preview */} {leagueStandingsSummaries.length > 0 && ( diff --git a/docs/architecture/AUTH_REFACTOR_SUMMARY.md b/docs/architecture/AUTH_REFACTOR_SUMMARY.md deleted file mode 100644 index c4a31850e..000000000 --- a/docs/architecture/AUTH_REFACTOR_SUMMARY.md +++ /dev/null @@ -1,287 +0,0 @@ -# Authentication & Authorization Refactor Summary - -## Problem Statement -The website had a "fucking unpredictable mess" of authorization and authentication layers: -- **RouteGuard** (old, complex) -- **AuthGuard** (old, complex) -- **AuthGateway** (deprecated) -- **AuthorizationBlocker** (deprecated) -- **Middleware** with hardcoded paths -- **Role logic scattered** across client and server -- **Inconsistent patterns** across routes - -## The Clean Solution - -### 1. Centralized Route Configuration -**File:** `apps/website/lib/routing/RouteConfig.ts` - -```typescript -// Single source of truth for ALL routes -export const routes = { - dashboard: { - path: '/dashboard', - auth: true, - roles: ['driver', 'team_manager', 'sponsor'], - redirect: '/login' - }, - admin: { - path: '/admin', - auth: true, - roles: ['admin'], - redirect: '/unauthorized' - }, - // ... and more -} -``` - -**Benefits:** -- โœ… No hardcoded paths anywhere -- โœ… Type-safe route definitions -- โœ… i18n-ready (switch locales by changing config) -- โœ… Easy to maintain - -### 2. Clean Middleware -**File:** `apps/website/middleware.ts` - -```typescript -// Before: Complex logic with hardcoded paths -// After: Simple cookie check + redirect using route config - -export async function middleware(req: NextRequest) { - const pathname = req.nextUrl.pathname; - - // Find matching route - const route = routes.getRouteByPath(pathname); - - if (route?.auth && !hasAuthCookie(req)) { - return NextResponse.redirect(new URL(route.redirect, req.url)); - } - - return NextResponse.next(); -} -``` - -**Benefits:** -- โœ… Uses route config exclusively -- โœ… No role logic in middleware -- โœ… Predictable flow -- โœ… Easy to debug - -### 3. Clean Guards (TDD Implementation) - -#### AuthGuard -**File:** `apps/website/lib/guards/AuthGuard.tsx` - -```typescript -// Only checks authentication -export class AuthGuard { - async check(session: Session | null): Promise { - return session !== null; - } - - async enforce(session: Session | null): Promise { - if (!await this.check(session)) { - throw new AuthError('Not authenticated'); - } - } -} -``` - -#### RoleGuard -**File:** `apps/website/lib/guards/RoleGuard.tsx` - -```typescript -// Only checks roles -export class RoleGuard { - async check(session: Session | null, requiredRoles: string[]): Promise { - if (!session?.user?.roles) return false; - return requiredRoles.some(role => session.user.roles.includes(role)); - } - - async enforce(session: Session | null, requiredRoles: string[]): Promise { - if (!await this.check(session, requiredRoles)) { - throw new AuthorizationError('Insufficient permissions'); - } - } -} -``` - -**Benefits:** -- โœ… Single responsibility -- โœ… Class-based (easy to test) -- โœ… Full TDD coverage -- โœ… Predictable behavior - -### 4. Updated Route Layouts -**All 7 layouts updated:** - -```typescript -// Before: Mixed old guards, hardcoded paths -import { RouteGuard } from '@/lib/gateways/RouteGuard'; -import { AuthGateway } from '@/lib/gateways/AuthGateway'; - -// After: Clean guards with route config -import { AuthGuard } from '@/lib/guards/AuthGuard'; -import { RoleGuard } from '@/lib/guards/RoleGuard'; -import { routes } from '@/lib/routing/RouteConfig'; - -export default async function DashboardLayout({ children }) { - const session = await getSession(); - const authGuard = new AuthGuard(); - const roleGuard = new RoleGuard(); - - await authGuard.enforce(session); - await roleGuard.enforce(session, routes.dashboard.roles); - - return <>{children}; -} -``` - -### 5. Comprehensive Tests - -**TDD Applied:** -- `AuthGuard.test.tsx` - Full coverage -- `RoleGuard.test.tsx` - Full coverage -- `auth-flow-clean.test.ts` - Integration tests - -**Test Structure:** -```typescript -describe('AuthGuard', () => { - it('should pass when authenticated', async () => { - const guard = new AuthGuard(); - const result = await guard.check(mockSession); - expect(result).toBe(true); - }); - - it('should fail when not authenticated', async () => { - const guard = new AuthGuard(); - await expect(guard.enforce(null)).rejects.toThrow(AuthError); - }); -}); -``` - -## Architecture Flow - -### Request Flow (Clean) -``` -1. User requests /dashboard - โ†“ -2. Middleware checks route config - โ†“ -3. If auth required โ†’ check cookie - โ†“ -4. If no cookie โ†’ redirect to login - โ†“ -5. If authenticated โ†’ load layout - โ†“ -6. AuthGuard.enforce() โ†’ verify session - โ†“ -7. RoleGuard.enforce() โ†’ verify roles - โ†“ -8. Render page -``` - -### Old Flow (Chaotic) -``` -1. User requests /dashboard - โ†“ -2. Middleware checks hardcoded paths - โ†“ -3. RouteGuard checks (complex logic) - โ†“ -4. AuthGuard checks (duplicate logic) - โ†“ -5. AuthGateway checks (deprecated) - โ†“ -6. AuthorizationBlocker checks - โ†“ -7. Layout guards check again - โ†“ -8. Maybe render, maybe not -``` - -## Files Created - -### New Files -- `apps/website/lib/routing/RouteConfig.ts` - Central routing -- `apps/website/lib/guards/AuthGuard.tsx` - Auth guard -- `apps/website/lib/guards/AuthGuard.test.tsx` - Tests -- `apps/website/lib/guards/RoleGuard.tsx` - Role guard -- `apps/website/lib/guards/RoleGuard.test.tsx` - Tests -- `tests/integration/website/auth-flow-clean.test.ts` - Integration -- `docs/architecture/CLEAN_AUTH_SOLUTION.md` - Architecture guide - -### Modified Files -- `apps/website/middleware.ts` - Clean middleware -- `apps/website/app/dashboard/layout.tsx` - Updated -- `apps/website/app/profile/layout.tsx` - Updated -- `apps/website/app/sponsor/layout.tsx` - Updated -- `apps/website/app/onboarding/layout.tsx` - Updated -- `apps/website/app/admin/layout.tsx` - Updated -- `apps/website/app/admin/users/page.tsx` - Updated - -### Deleted Files -- โŒ `apps/website/lib/gateways/` (entire directory) -- โŒ `apps/website/lib/blockers/AuthorizationBlocker.ts` - -## Key Benefits - -### โœ… Predictability -- One clear path for every request -- No hidden logic -- Easy to trace - -### โœ… Maintainability -- Single source of truth (RouteConfig) -- No duplication -- Easy to add new routes - -### โœ… Testability -- Class-based guards -- Full TDD coverage -- Integration tests - -### โœ… Flexibility -- i18n ready -- Role-based access -- Easy to extend - -### โœ… Developer Experience -- Type-safe -- Clear errors -- Good documentation - -## Migration Checklist - -- [x] Analyze current chaos -- [x] Define responsibilities -- [x] Design unified concept -- [x] Create RouteConfig.ts -- [x] Update middleware.ts -- [x] Create AuthGuard -- [x] Create RoleGuard -- [x] Update all layouts -- [x] Write comprehensive tests -- [x] Document architecture -- [x] Verify compilation -- [x] Remove old files - -## Next Steps - -1. **Start API server** for full integration testing -2. **Run tests** to verify everything works -3. **Test edge cases** (expired sessions, role changes) -4. **Monitor production** for any issues -5. **Document any additional patterns** discovered - -## Summary - -This refactor transforms the "unpredictable mess" into a **clean, predictable, and maintainable** authentication system: - -- **1 central config** instead of scattered paths -- **2 clean guards** instead of 5+ overlapping layers -- **Full TDD coverage** for reliability -- **Clear separation** of concerns -- **Easy to debug** and extend - -The architecture is now ready for i18n, new routes, and future enhancements without adding complexity. diff --git a/docs/architecture/CLEAN_AUTH_SOLUTION.md b/docs/architecture/CLEAN_AUTH_SOLUTION.md deleted file mode 100644 index 504f0c68d..000000000 --- a/docs/architecture/CLEAN_AUTH_SOLUTION.md +++ /dev/null @@ -1,374 +0,0 @@ -# Clean Authentication & Authorization Solution - -## Overview - -This document describes the **clean, predictable, and maintainable** authentication and authorization architecture that replaces the previous "fucking unpredictable mess." - -## The Problem - -**Before:** -- Multiple overlapping protection layers (middleware, RouteGuard, AuthGuard, Blockers, Gateways) -- Hardcoded paths scattered throughout codebase -- Mixed responsibilities between server and client -- Inconsistent patterns across routes -- Role logic in both client and server -- Debugging nightmare with unclear flow - -## The Solution - -### Core Principle: **Single Source of Truth** - -All routing decisions flow through **one centralized configuration system**: - -```typescript -// apps/website/lib/routing/RouteConfig.ts -export const routes = { - auth: { - login: '/auth/login', - signup: '/auth/signup', - // ... all auth routes - }, - public: { - home: '/', - leagues: '/leagues', - // ... all public routes - }, - protected: { - dashboard: '/dashboard', - // ... all protected routes - }, - sponsor: { - dashboard: '/sponsor/dashboard', - // ... sponsor routes - }, - admin: { - root: '/admin', - users: '/admin/users', - }, - league: { - detail: (id: string) => `/leagues/${id}`, - // ... parameterized routes - }, - // ... etc -}; -``` - -### Architecture Layers - -#### 1. **Edge Middleware** (Simple & Clean) -```typescript -// apps/website/middleware.ts -export function middleware(request: NextRequest) { - const hasAuthCookie = request.cookies.has('gp_session'); - - // Public routes from config - const publicRoutes = [ - routes.public.home, - routes.public.leagues, - routes.auth.login, - // ... etc - ]; - - if (publicRoutes.includes(pathname)) { - // Handle auth route redirects - return NextResponse.next(); - } - - // Protected routes - if (!hasAuthCookie) { - const loginUrl = new URL(routes.auth.login, request.url); - loginUrl.searchParams.set('returnTo', pathname); - return NextResponse.redirect(loginUrl); - } - - return NextResponse.next(); -} -``` - -**Responsibilities:** -- โœ… Check session cookie -- โœ… Allow public routes -- โœ… Redirect to login if no cookie -- โŒ No role checking -- โŒ No hardcoded paths - -#### 2. **Client Guards** (UX Enhancement) -```typescript -// apps/website/lib/guards/AuthGuard.tsx -export function AuthGuard({ children, requireAuth = true }: AuthGuardProps) { - const { session, loading } = useAuth(); - const router = useRouter(); - - useEffect(() => { - if (requireAuth && !loading && !session) { - const url = new URL(routes.auth.login, window.location.origin); - url.searchParams.set('returnTo', window.location.pathname); - router.push(url.toString()); - } - }, [session, loading]); - - if (loading) return ; - if (!session && requireAuth) return null; - return <>{children}; -} - -// apps/website/lib/guards/RoleGuard.tsx -export function RoleGuard({ children, requiredRoles }: RoleGuardProps) { - const { session, loading } = useAuth(); - const router = useRouter(); - - useEffect(() => { - if (!loading && session && !requiredRoles.includes(session.role)) { - router.push(routes.protected.dashboard); - } - }, [session, loading]); - - if (loading) return ; - if (!session || !requiredRoles.includes(session.role)) return null; - return <>{children}; -} -``` - -**Responsibilities:** -- โœ… Verify session exists -- โœ… Show loading states -- โœ… Redirect if unauthorized -- โœ… Hide UI elements -- โŒ Make security decisions - -#### 3. **API Guards** (Source of Truth) -```typescript -// apps/api/src/domain/auth/AuthorizationGuard.ts -@Injectable() -export class AuthorizationGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const isPublic = this.reflector.getMetadata('public', handler); - if (isPublic) return true; - - const request = context.switchToHttp().getRequest(); - const userId = request.user?.userId; - - if (!userId) { - throw new UnauthorizedException('Authentication required'); - } - - const rolesMetadata = this.reflector.getMetadata('roles', handler); - if (rolesMetadata) { - const userRoles = this.authorizationService.getRolesForUser(userId); - const hasRole = rolesMetadata.some(r => userRoles.includes(r)); - if (!hasRole) { - throw new ForbiddenException('Access denied'); - } - } - - return true; - } -} -``` - -**Responsibilities:** -- โœ… Verify authentication -- โœ… Check permissions -- โœ… Return 401/403 -- โŒ Redirect -- โŒ Trust client - -### Usage Examples - -#### Public Route -```typescript -// app/leagues/page.tsx -export default function LeaguesPage() { - return ; -} -// No protection needed -``` - -#### Authenticated Route -```typescript -// app/dashboard/layout.tsx -import { AuthGuard } from '@/lib/guards/AuthGuard'; - -export default function DashboardLayout({ children }) { - return ( - -
- {children} -
-
- ); -} - -// app/dashboard/page.tsx -export default function DashboardPage() { - return ; -} -``` - -#### Role-Protected Route -```typescript -// app/admin/layout.tsx -import { AuthGuard } from '@/lib/guards/AuthGuard'; -import { RoleGuard } from '@/lib/guards/RoleGuard'; - -export default function AdminLayout({ children }) { - return ( - - -
- {children} -
-
-
- ); -} -``` - -#### Scoped Route (League Admin) -```typescript -// app/leagues/[id]/settings/layout.tsx -import { AuthGuard } from '@/lib/guards/AuthGuard'; -import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard'; - -export default function LeagueSettingsLayout({ children, params }) { - return ( - - -
- {children} -
-
-
- ); -} -``` - -#### API Endpoint -```typescript -// apps/api/src/domain/league/LeagueController.ts -@Controller('leagues') -export class LeagueController { - @Get(':leagueId/admin') - @RequireAuthenticatedUser() - @RequireRoles('admin') - getLeagueAdmin(@Param('leagueId') leagueId: string) { - // Service verifies league-specific permissions - return this.leagueService.getAdminData(leagueId); - } -} -``` - -## Benefits - -### 1. **Predictable Flow** -``` -User Request โ†’ Middleware (check cookie) โ†’ API (auth + authz) โ†’ Controller โ†’ Response โ†’ Client (handle errors) -``` - -### 2. **Easy Debugging** -```bash -# Check middleware -curl -I http://localhost:3000/dashboard - -# Check API auth -curl -I http://localhost:3000/api/admin/users \ - -H "Cookie: gp_session=token" - -# Check client session -# Browser console: console.log(useAuth().session) -``` - -### 3. **i18n Ready** -```typescript -// Future: Switch locales by changing config -const routesDe = { ...routes, auth: { login: '/de/auth/login' } }; -const routesEs = { ...routes, auth: { login: '/es/auth/login' } }; - -// All code uses routes.auth.login, so switching is trivial -``` - -### 4. **Type Safety** -```typescript -// Compile-time checking -routes.league.detail('123'); // โœ… Works -routes.league.detail(); // โŒ Error: requires string - -// Parameter validation -const path = buildPath('league.detail', { id: '123' }); // โœ… -const path = buildPath('league.detail', {}); // โŒ Error -``` - -### 5. **Maintainable** -- **One file** to change all routes -- **No hardcoded paths** anywhere else -- **Clear separation** of concerns -- **Easy to test** each layer independently - -## Migration Checklist - -### Phase 1: Foundation (1 day) -- [x] Create `RouteConfig.ts` with all routes -- [x] Update `middleware.ts` to use route config -- [x] Remove hardcoded paths from middleware - -### Phase 2: Guards (2 days) -- [x] Create `AuthGuard.tsx` with route config -- [x] Create `RoleGuard.tsx` with route config -- [x] Remove old `RouteGuard` and `AuthGuard` files -- [x] Remove `AuthGateway` and `AuthorizationBlocker` - -### Phase 3: Route Updates (2 days) -- [ ] Update all route layouts to use new guards -- [ ] Remove redundant page-level checks -- [ ] Test all redirect flows - -### Phase 4: API Verification (1 day) -- [ ] Ensure all endpoints have proper decorators -- [ ] Add missing `@Public()` or `@RequireRoles()` -- [ ] Test 401/403 responses - -### Phase 5: Documentation & Testing (1 day) -- [ ] Update all route protection docs -- [ ] Create testing checklist -- [ ] Verify all scenarios work - -## Testing Checklist - -### Unauthenticated User -- [ ] `/dashboard` โ†’ Redirects to `/auth/login?returnTo=/dashboard` -- [ ] `/admin` โ†’ Redirects to `/auth/login?returnTo=/admin` -- [ ] `/leagues` โ†’ Works (public) -- [ ] `/auth/login` โ†’ Works (public) - -### Authenticated User (Regular) -- [ ] `/dashboard` โ†’ Works -- [ ] `/admin` โ†’ Redirects to `/dashboard` (no role) -- [ ] `/leagues` โ†’ Works (public) -- [ ] `/auth/login` โ†’ Redirects to `/dashboard` - -### Authenticated User (Admin) -- [ ] `/dashboard` โ†’ Works -- [ ] `/admin` โ†’ Works -- [ ] `/admin/users` โ†’ Works - -### Session Expiry -- [ ] Navigate to protected route with expired session โ†’ Redirect to login -- [ ] Return to original route after login โ†’ Works - -### API Direct Calls -- [ ] Call protected endpoint without auth โ†’ 401 -- [ ] Call admin endpoint without role โ†’ 403 -- [ ] Call public endpoint โ†’ 200 - -## Summary - -This architecture eliminates the chaos by: - -1. **One Source of Truth**: All routes in `RouteConfig.ts` -2. **Clear Layers**: Middleware โ†’ API โ†’ Guards โ†’ Controller -3. **No Hardcoded Paths**: Everything uses the config -4. **i18n Ready**: Easy to add localized routes -5. **Type Safe**: Compile-time route validation -6. **Easy to Debug**: Each layer has one job - -**Result**: Clean, predictable, secure authentication that just works. \ No newline at end of file diff --git a/docs/architecture/QUICK_AUTH_REFERENCE.md b/docs/architecture/QUICK_AUTH_REFERENCE.md deleted file mode 100644 index af0ad5703..000000000 --- a/docs/architecture/QUICK_AUTH_REFERENCE.md +++ /dev/null @@ -1,276 +0,0 @@ -# Quick Reference: Clean Authentication & Authorization - -## The Golden Rules - -1. **API is the source of truth** - Never trust the client for security -2. **Client is UX only** - Redirect, show loading, hide buttons -3. **One clear flow** - Middleware โ†’ API โ†’ Guard โ†’ Controller -4. **Roles are server-side** - Client only knows "can access" or "can't" - -## What Goes Where - -### Server-Side (API) -```typescript -// โœ… DO: Check permissions -@RequireRoles('admin') -@Get('users') -getUsers() { ... } - -// โœ… DO: Return 401/403 -throw new UnauthorizedException('Auth required') -throw new ForbiddenException('No permission') - -// โŒ DON'T: Redirect -res.redirect('/login') // Never do this - -// โŒ DON'T: Trust client identity -const userId = req.body.userId // Wrong! -const userId = req.user.userId // Correct -``` - -### Client-Side (Website) -```typescript -// โœ… DO: Redirect unauthenticated users -if (!session && !loading) { - router.push('/auth/login') -} - -// โœ… DO: Show loading states -if (loading) return - -// โœ… DO: Hide UI elements -{canAccess && } - -// โŒ DON'T: Make security decisions -if (user.role === 'admin') // Wrong! API decides - -// โŒ DON'T: Trust your own checks -// Client checks are UX only, API is the gatekeeper -``` - -## Route Protection Patterns - -### Public Route -```typescript -// app/leagues/page.tsx -export default function LeaguesPage() { - return ; -} -// No protection needed - accessible by all -``` - -### Authenticated Route -```typescript -// app/dashboard/layout.tsx -import { AuthLayout } from '@/lib/guards/AuthLayout'; - -export default function DashboardLayout({ children }) { - return {children}; -} - -// app/dashboard/page.tsx -export default function DashboardPage() { - return ; -} -// Layout handles auth check, page is clean -``` - -### Role-Protected Route -```typescript -// app/admin/layout.tsx -import { RoleLayout } from '@/lib/guards/RoleLayout'; - -export default function AdminLayout({ children }) { - return ( - - {children} - - ); -} - -// app/admin/page.tsx -export default function AdminPage() { - return ; -} -// Layout handles role check -``` - -### Scoped Route (League Admin) -```typescript -// app/leagues/[id]/settings/layout.tsx -import { AuthLayout } from '@/lib/guards/AuthLayout'; -import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard'; - -export default function LeagueSettingsLayout({ children, params }) { - return ( - - - {children} - - - ); -} -// Multiple guards for complex scenarios -``` - -## API Endpoint Patterns - -### Public Endpoint -```typescript -@Public() -@Get('pricing') -getPricing() { ... } -// No auth required -``` - -### Authenticated Endpoint -```typescript -@RequireAuthenticatedUser() -@Get('profile') -getProfile(@User() user: UserEntity) { ... } -// Any logged-in user -``` - -### Role-Protected Endpoint -```typescript -@RequireRoles('admin') -@Get('users') -getUsers() { ... } -// Only admins -``` - -### Scoped Endpoint -```typescript -@RequireAuthenticatedUser() -@Get('leagues/:leagueId/admin') -getLeagueAdmin( - @Param('leagueId') leagueId: string, - @User() user: UserEntity -) { - // Check if user is league admin - this.leagueService.verifyLeagueAdmin(leagueId, user.id); - ... -} -// Check scope in service -``` - -## Error Handling - -### API Returns -- **401 Unauthorized**: No/invalid session -- **403 Forbidden**: Has session but no permission -- **404 Not Found**: Resource doesn't exist OR non-disclosure - -### Client Handles -```typescript -try { - const data = await apiFetch('/api/admin/users'); - return data; -} catch (error) { - if (error.message.includes('401')) { - // Redirect to login - window.location.href = '/auth/login'; - } else if (error.message.includes('403')) { - // Show access denied - toast.error('You need admin access'); - router.push('/dashboard'); - } else { - // Show error - toast.error(error.message); - } -} -``` - -## Common Mistakes - -### โŒ Wrong -```typescript -// Client making security decisions -function AdminPage() { - const { session } = useAuth(); - if (session?.role !== 'admin') return ; - return ; -} - -// API trusting client -@Post('delete') -deleteUser(@Body() body: { userId: string }) { - const userId = body.userId; // Could be anyone! - ... -} - -// Middleware doing too much -if (user.role === 'admin') { // Wrong place for this! - return NextResponse.next(); -} -``` - -### โœ… Correct -```typescript -// Client handles UX only -function AdminPage() { - return ( - - - - ); -} - -// API is source of truth -@Post('delete') -@RequireRoles('admin') -deleteUser(@User() user: UserEntity, @Body() body: { userId: string }) { - // user.id is from session, body.userId is target - // Service verifies permissions - ... -} - -// Middleware only checks auth -if (!hasAuthCookie) { - return redirect('/login'); -} -// Let API handle roles -``` - -## Testing Checklist - -### Before Deploy -- [ ] Unauthenticated user can't access protected routes -- [ ] Authenticated user can access their routes -- [ ] Wrong role gets redirected/denied -- [ ] Session expiry redirects to login -- [ ] API returns proper 401/403 codes -- [ ] Public routes work without login - -### Quick Test Commands -```bash -# Test API directly -curl -I http://localhost:3000/api/admin/users -# Should return 401 (no auth) - -# Test with session -curl -I http://localhost:3000/api/admin/users \ - -H "Cookie: gp_session=valid_token" -# Should return 200 or 403 depending on role - -# Test public route -curl -I http://localhost:3000/api/leagues/all -# Should return 200 -``` - -## Migration Steps - -1. **Simplify middleware** - Remove role logic -2. **Create clean guards** - AuthLayout, RoleLayout -3. **Update layouts** - Replace old RouteGuard -4. **Test all routes** - Check redirects work -5. **Verify API** - All endpoints have proper decorators - -## Remember - -- **Server**: Security, permissions, data filtering -- **Client**: UX, loading states, redirects -- **Flow**: Always the same, always predictable -- **Debug**: Check each layer in order - -**When in doubt**: The API decides. The client just shows what the API says. \ No newline at end of file diff --git a/docs/architecture/AUTHORIZATION.md b/docs/architecture/api/AUTHORIZATION.md similarity index 100% rename from docs/architecture/AUTHORIZATION.md rename to docs/architecture/api/AUTHORIZATION.md diff --git a/docs/architecture/CQRS.md b/docs/architecture/core/CQRS.md similarity index 100% rename from docs/architecture/CQRS.md rename to docs/architecture/core/CQRS.md diff --git a/docs/architecture/DOMAIN_OBJECTS.md b/docs/architecture/core/DOMAIN_OBJECTS.md similarity index 100% rename from docs/architecture/DOMAIN_OBJECTS.md rename to docs/architecture/core/DOMAIN_OBJECTS.md diff --git a/docs/architecture/ENUMS.md b/docs/architecture/core/ENUMS.md similarity index 100% rename from docs/architecture/ENUMS.md rename to docs/architecture/core/ENUMS.md diff --git a/docs/architecture/USECASES.md b/docs/architecture/core/USECASES.md similarity index 100% rename from docs/architecture/USECASES.md rename to docs/architecture/core/USECASES.md diff --git a/docs/architecture/ADAPTERS.md b/docs/architecture/shared/ADAPTERS.md similarity index 100% rename from docs/architecture/ADAPTERS.md rename to docs/architecture/shared/ADAPTERS.md diff --git a/docs/architecture/DATA_FLOW.md b/docs/architecture/shared/DATA_FLOW.md similarity index 87% rename from docs/architecture/DATA_FLOW.md rename to docs/architecture/shared/DATA_FLOW.md index 5dcfab248..030286ef2 100644 --- a/docs/architecture/DATA_FLOW.md +++ b/docs/architecture/shared/DATA_FLOW.md @@ -241,10 +241,13 @@ export class CreateLeaguePresenter implements CreateLeagueOutputPort { The frontend layer contains UI-specific data shapes. None of these cross into the Core. -There are three distinct frontend data concepts: +Important: `apps/website` is a Next.js delivery app with SSR/RSC. This introduces one additional presentation concept to keep server/client boundaries correct. + +There are four distinct frontend data concepts: 1. API DTOs (transport) 2. Command Models (user input / form state) - 3. View Models (UI display state) + 3. View Models (client-only presentation classes) + 4. ViewData (template input, serializable) โธป @@ -311,6 +314,10 @@ Rules: โ€ข No domain logic โ€ข No mutation after construction +SSR/RSC rule (website-only): + โ€ข View Models are client-only and MUST NOT cross server-to-client boundaries. + โ€ข Templates MUST NOT accept View Models. + โธป 8.4 Website Presenters (DTO โ†’ ViewModel) @@ -427,8 +434,9 @@ UI Component โ€ข Core has NO Models, DTOs, or ViewModels โ€ข API talks ONLY to Application Services โ€ข Controllers NEVER call Use Cases directly - โ€ข Frontend Components see ONLY View Models - โ€ข DTOs never cross into UI components + โ€ข Frontend Components see ONLY ViewData (Templates) or ViewModels (Client orchestrators) + โ€ข API DTOs never cross into Templates + โ€ข View Models never cross into Templates โธป @@ -439,4 +447,22 @@ Application Services orchestrate. Adapters translate. UI presents. -If a class violates more than one of these roles, it is incorrectly placed. \ No newline at end of file +If a class violates more than one of these roles, it is incorrectly placed. +8.3.1 ViewData (Template Input) + +ViewData is the only allowed input for Templates in `apps/website`. + +Definition: + โ€ข JSON-serializable data structure + โ€ข Contains only primitives/arrays/plain objects + โ€ข Ready to render: Templates perform no formatting and no derived computation + +Rules: + โ€ข ViewData is built in client code from: + 1) Page DTO (initial SSR-safe render) + 2) ViewModel (post-hydration enhancement) + โ€ข ViewData MUST NOT contain ViewModel instances or Display Object instances. + +Authoritative details: + โ€ข [docs/architecture/website/VIEW_DATA.md](docs/architecture/website/VIEW_DATA.md:1) + โ€ข [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) diff --git a/docs/architecture/FEATURE_AVAILABILITY.md b/docs/architecture/shared/FEATURE_AVAILABILITY.md similarity index 100% rename from docs/architecture/FEATURE_AVAILABILITY.md rename to docs/architecture/shared/FEATURE_AVAILABILITY.md diff --git a/docs/architecture/FILE_STRUCTURE.md b/docs/architecture/shared/FILE_STRUCTURE.md similarity index 100% rename from docs/architecture/FILE_STRUCTURE.md rename to docs/architecture/shared/FILE_STRUCTURE.md diff --git a/docs/architecture/LOGGING.md b/docs/architecture/shared/LOGGING.md similarity index 100% rename from docs/architecture/LOGGING.md rename to docs/architecture/shared/LOGGING.md diff --git a/docs/architecture/SERVICES.md b/docs/architecture/shared/SERVICES.md similarity index 100% rename from docs/architecture/SERVICES.md rename to docs/architecture/shared/SERVICES.md diff --git a/docs/architecture/UNIFIED_AUTH_CONCEPT.md b/docs/architecture/shared/UNIFIED_AUTH_CONCEPT.md similarity index 100% rename from docs/architecture/UNIFIED_AUTH_CONCEPT.md rename to docs/architecture/shared/UNIFIED_AUTH_CONCEPT.md diff --git a/docs/architecture/BLOCKER_GUARDS.md b/docs/architecture/website/BLOCKER_GUARDS.md similarity index 100% rename from docs/architecture/BLOCKER_GUARDS.md rename to docs/architecture/website/BLOCKER_GUARDS.md diff --git a/docs/architecture/COMMAND_MODELS.md b/docs/architecture/website/COMMAND_MODELS.md similarity index 100% rename from docs/architecture/COMMAND_MODELS.md rename to docs/architecture/website/COMMAND_MODELS.md diff --git a/docs/architecture/DISPLAY_OBJECTS.md b/docs/architecture/website/DISPLAY_OBJECTS.md similarity index 53% rename from docs/architecture/DISPLAY_OBJECTS.md rename to docs/architecture/website/DISPLAY_OBJECTS.md index ce6917ce1..65b0b1a87 100644 --- a/docs/architecture/DISPLAY_OBJECTS.md +++ b/docs/architecture/website/DISPLAY_OBJECTS.md @@ -4,6 +4,13 @@ A **Display Object** encapsulates **reusable, UI-only display logic**. +In this codebase, a Display Object is a **Frontend Value Object**: + +- class-based +- immutable +- deterministic +- side-effect free + It answers the question: > โ€œHow should this specific piece of information be shown?โ€ @@ -23,12 +30,23 @@ A Display Object MAY: - encapsulate UI display conventions - be reused across multiple View Models +In addition, a Display Object MAY: + +- normalize presentation inputs (for example trimming/casing) +- expose multiple explicit display variants (for example `shortLabel`, `longLabel`) + A Display Object MUST: - be deterministic - be side-effect free - operate only on presentation data +A Display Object MUST: + +- be implemented as a **class** with a small, explicit API +- accept only primitives/plain data in its constructor (or static factory) +- expose only primitive outputs (strings/numbers/booleans) + --- ## Restrictions @@ -42,6 +60,13 @@ A Display Object MUST NOT: - be sent back to the server - depend on backend or infrastructure concerns +In this repository, a Display Object MUST NOT: + +- call `Intl.*` +- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()` + +Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches. + If a rule affects system correctness or persistence, it does not belong in a Display Object. @@ -53,6 +78,10 @@ it does not belong in a Display Object. - They are frontend-only - They are not shared with the backend or core +Placement rule (strict): + +- Display Objects live under `apps/website/lib/display-objects/*`. + --- ## Relationship to View Models @@ -62,6 +91,13 @@ it does not belong in a Display Object. - Display Objects represent **parts** - View Models represent **screens** +Additional strict rules: + +- View Models SHOULD compose Display Objects. +- Display Objects MUST NOT be serialized or passed across boundaries. + - They must not appear in server-to-client DTOs. + - Templates should receive primitive display outputs, not Display Object instances. + --- ## Testing @@ -72,6 +108,11 @@ Display Objects SHOULD be tested because they often contain: - formatting rules - edge cases visible to users +Additionally: + +- test determinism by running the same inputs under Node and browser contexts (where applicable) +- test boundary rules (no `Intl.*`, no `toLocale*`) + --- ## Summary @@ -79,4 +120,6 @@ Display Objects SHOULD be tested because they often contain: - Display Objects encapsulate **how something looks** - View Models encapsulate **what a screen needs** - Both are presentation concerns -- Neither contains business truth \ No newline at end of file +- Neither contains business truth + +In one sentence: Display Objects are **Value Objects for UI display**, not utility functions. diff --git a/docs/architecture/FORM_SUBMISSION.md b/docs/architecture/website/FORM_SUBMISSION.md similarity index 100% rename from docs/architecture/FORM_SUBMISSION.md rename to docs/architecture/website/FORM_SUBMISSION.md diff --git a/docs/architecture/LOGIN_FLOW_STATE_MACHINE.md b/docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md similarity index 100% rename from docs/architecture/LOGIN_FLOW_STATE_MACHINE.md rename to docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md diff --git a/docs/architecture/website/VIEW_DATA.md b/docs/architecture/website/VIEW_DATA.md new file mode 100644 index 000000000..00281c744 --- /dev/null +++ b/docs/architecture/website/VIEW_DATA.md @@ -0,0 +1,46 @@ +# ViewData (Website Templates) + +ViewData is the **only** allowed input type for Templates in `apps/website`. + +## 1) Definition + +ViewData is a JSON-serializable, template-ready data structure: + +- primitives (strings/numbers/booleans) +- arrays and plain objects +- `null` for missing values + +## 2) What ViewData is NOT + +ViewData is not: + +- a Page DTO (raw transport) +- a ViewModel (client-only class) +- a Display Object instance + +## 3) Construction rules + +ViewData MUST be created in client code: + +1) Initial SSR-safe render: `ViewData = fromDTO(PageDTO)` +2) Post-hydration render: `ViewData = fromViewModel(ViewModel)` + +Templates MUST NOT compute derived values. + +## 4) Determinism rules + +Any formatting used to produce ViewData MUST be deterministic. + +Forbidden anywhere in formatting code paths: + +- `Intl.*` +- `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()` + +Reason: SSR and browser outputs can differ. + +## 5) Relationship to Display Objects + +Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData. + +Only primitive outputs produced by Display Objects may appear in ViewData. + diff --git a/docs/architecture/VIEW_MODELS.md b/docs/architecture/website/VIEW_MODELS.md similarity index 67% rename from docs/architecture/VIEW_MODELS.md rename to docs/architecture/website/VIEW_MODELS.md index 25e84f7a4..e58de2f09 100644 --- a/docs/architecture/VIEW_MODELS.md +++ b/docs/architecture/website/VIEW_MODELS.md @@ -24,6 +24,8 @@ A View Model MAY: - handle localization and presentation logic - use Display Objects for reusable UI concerns +In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates. + A View Model MUST: - be fully usable by the UI without further computation @@ -58,11 +60,20 @@ that logic belongs in the Core, not here. ## Creation Rules -- View Models are created from API DTOs -- DTOs never reach pages/components; map DTO โ†’ ViewModel in website services -- UI components must never construct View Models themselves -- Construction happens in services or presentation layers -- The UI only consumes View Models, never DTOs +This repository distinguishes **Page DTO**, **ViewModel**, and **ViewData**: + +- Page DTO: server-to-client payload (JSON-serializable) +- ViewModel: client-only class (never serialized) +- ViewData: template input (JSON-serializable) + +Rules (website): + +1) View Models are created in client code only. +2) View Models are created from Page DTOs. +3) Templates MUST NOT accept View Models; Templates accept ViewData only. +4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only). + +Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1). --- @@ -83,4 +94,4 @@ View Models do NOT need tests if they only expose data without logic. - View Models describe **UI state** - They are **presentation-focused**, not business-focused - They reduce complexity in components -- They form a stable contract for the UI \ No newline at end of file +- They form a stable contract for the UI diff --git a/docs/architecture/website/WEBSITE_DI_RULES.md b/docs/architecture/website/WEBSITE_DI_RULES.md new file mode 100644 index 000000000..849e6bb5a --- /dev/null +++ b/docs/architecture/website/WEBSITE_DI_RULES.md @@ -0,0 +1,30 @@ +# Website DI Rules (Inversify) + +This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1). + +## 1) Non-negotiable safety rule + +No stateful service instances may be shared across requests. + +Reason: Next.js server execution is concurrent; shared state causes cross-request leakage. + +## 2) Rules by module type + +### 2.1 `page.tsx` (server) + +- MUST NOT access the DI container directly. +- MUST call a PageQuery only. + +### 2.2 Page Queries (server) + +- SHOULD prefer explicit construction (manual wiring). +- MAY use DI only if all resolved services are stateless and safe for concurrent requests. + +### 2.3 Client modules + +- MAY use DI via `ContainerProvider` and hooks (example: `useInject`). + +## 3) Container singleton warning + +[`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container. Treat it as **unsafe for server request scope** unless proven otherwise. + diff --git a/docs/architecture/website/WEBSITE_GUARDRAILS.md b/docs/architecture/website/WEBSITE_GUARDRAILS.md new file mode 100644 index 000000000..126bf25b5 --- /dev/null +++ b/docs/architecture/website/WEBSITE_GUARDRAILS.md @@ -0,0 +1,30 @@ +# Website Guardrails (Mandatory) + +This document defines architecture guardrails that must be enforced via tests + ESLint. + +## 1) RSC boundary guardrails + +Fail CI if any `apps/website/app/**/page.tsx`: + +- imports from `apps/website/lib/view-models/*` +- calls `Intl.*` or `toLocale*` +- performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks + +## 2) Template purity guardrails + +Fail CI if any `apps/website/templates/**`: + +- imports from `apps/website/lib/view-models/*` +- imports from `apps/website/lib/display-objects/*` +- calls `Intl.*` or `toLocale*` + +Templates accept ViewData only. + +## 3) Display Object guardrails + +Fail CI if any `apps/website/lib/display-objects/**`: + +- calls `Intl.*` or `toLocale*` + +Display Objects must be deterministic. + diff --git a/docs/architecture/website/WEBSITE_PAGE_QUERIES.md b/docs/architecture/website/WEBSITE_PAGE_QUERIES.md new file mode 100644 index 000000000..0e55fb335 --- /dev/null +++ b/docs/architecture/website/WEBSITE_PAGE_QUERIES.md @@ -0,0 +1,39 @@ +# Website Page Queries (Server) + +This document defines the only allowed server-side data fetching shape for `apps/website` routes. + +## 1) Purpose + +Page Queries are server-side composition classes that: + +- call services that call `apps/api` +- assemble a Page DTO +- return an explicit result describing route outcome + +They do not implement business rules. + +## 2) Result type (no null) + +Page Queries MUST return a discriminated union (`PageQueryResult`): + +- `ok` with `{ dto }` +- `notFound` +- `redirect` with `{ to }` +- `error` with `{ errorId }` + +Pages MUST switch on this result and call: + +- `notFound()` for `notFound` +- `redirect()` for `redirect` + +## 3) Forbidden responsibilities + +Page Queries MUST NOT: + +- format values for display +- sort/filter (canonical or view-only) +- instantiate ViewModels +- instantiate Display Objects + +If sorting/filtering is needed, it MUST be added to `apps/api`. + diff --git a/docs/architecture/website/WEBSITE_RSC_PRESENTATION.md b/docs/architecture/website/WEBSITE_RSC_PRESENTATION.md new file mode 100644 index 000000000..0cd661ec6 --- /dev/null +++ b/docs/architecture/website/WEBSITE_RSC_PRESENTATION.md @@ -0,0 +1,60 @@ +# Website RSC Presentation Architecture (Strict) + +This document defines the only allowed presentation architecture for `apps/website` (Next.js App Router). + +It is **website-only** and does not change `apps/api` or `core` architecture. + +## 1) Core rule: API owns business truth + +- `apps/api` is the only source of truth for business rules and canonical filtering/sorting. +- `apps/website` is presentation infrastructure: composition, routing, caching, and rendering. + +## 2) The three website presentation data types + +### 2.1 Page DTO + +**Purpose:** server-to-client payload. + +**Rules:** + +- JSON-serializable only. +- Contains raw values only (ISO date strings, numbers, codes). +- MUST NOT contain class instances. + +### 2.2 ViewModel + +**Purpose:** client-only presentation model. + +**Rules:** + +- Class-based. +- Instantiated only in `'use client'` modules. +- Composes Display Objects. +- NEVER passed into Templates. + +### 2.3 ViewData + +**Purpose:** Template input. + +**Rules:** + +- JSON-serializable only. +- Contains only values ready to render (mostly strings/numbers). +- Built from Page DTO (initial render) and from ViewModel (post-hydration). + +## 3) Required per-route structure + +Every route MUST follow: + +1) `page.tsx` (server): calls a PageQuery and passes Page DTO +2) `*PageClient.tsx` (client): builds ViewData and renders Template +3) `*Template.tsx` (pure UI): renders ViewData only + +## 4) Authoritative specification + +This document is an entry point only. + +The authoritative, test-enforced spec lives at: + +- [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) + diff --git a/plans/nextjs-rsc-viewmodels-concept.md b/plans/nextjs-rsc-viewmodels-concept.md new file mode 100644 index 000000000..a53ee7d20 --- /dev/null +++ b/plans/nextjs-rsc-viewmodels-concept.md @@ -0,0 +1,415 @@ +# Next.js RSC + Client ViewModels + Display Objects (STRICT) + +This document is FINAL and STRICT. No alternative interpretations. + +## 1) System boundary (non-negotiable) + +1. `apps/api` is the single source of truth for: + +- business rules +- validation +- authorization decisions +- canonical filtering and canonical sorting + +2. `apps/website` is presentation infrastructure: + +- renders UI using Next.js App Router +- consumes `apps/api` via existing clients/services +- performs routing/session/caching/composition +- MUST NOT replicate business truth + +## 2) Layering rules + +### 2.1 Server route entry modules are composition-only + +All `page.tsx` modules under [apps/website/app](apps/website/app/page.tsx:1) are composition-only. + +`page.tsx` modules MAY: + +- read `params` / `searchParams` +- call [`redirect()`](apps/website/app/leaderboards/page.tsx:7) or [`notFound()`](apps/website/app/dashboard/page.tsx:1) +- call a server-side query class +- render server and client components + +`page.tsx` modules MUST NOT: + +- instantiate ViewModels (example forbidden: [`new DriverProfileViewModel()`](apps/website/lib/view-models/DriverProfileViewModel.ts:108)) +- implement formatting (dates, localization, percent, currency) +- implement filtering/sorting (canonical or view-only) +- map API payloads into UI-specific shapes +- define reusable helper functions + +### 2.2 Website server query classes (presentation queries) + +Each route MUST have exactly one server query class: + +- `apps/website/lib/page-queries/PageQuery.ts` + +The query class MUST: + +- call services that call `apps/api` (example current service: [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:10)) +- return a Page DTO (defined below) +- contain no formatting/filtering/sorting + +The query class MUST NOT: + +- contain business rules +- contain canonical ordering decisions + +If ordering/filtering is needed, it MUST be implemented in `apps/api`. + +### 2.3 Client ViewModels + +ViewModels live in [apps/website/lib/view-models](apps/website/lib/view-models/DriverProfileViewModel.ts:1). + +ViewModels MUST: + +- be instantiated only in client modules (`'use client'`) +- accept DTOs only (plain data) +- expose view-only derived values (never business truth) + +ViewModels MUST NOT be passed into Templates. + +ViewModels SHOULD be the primary place that *composes* Display Objects. + +### 2.4 Display Objects + +Display Objects follow [docs/architecture/DISPLAY_OBJECTS.md](docs/architecture/DISPLAY_OBJECTS.md:1). + +Display Objects MUST: + +- live under `apps/website/lib/display-objects/*` (example existing: [apps/website/lib/display-objects/LeagueRoleDisplay.ts](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1)) +- be deterministic and side-effect free +- be the ONLY place where formatting/mapping conventions exist + +Pages MUST NOT format. Templates MUST NOT format. + +Display Objects lifecycle (strict): + +- Display Objects are created in client code. +- Display Objects are typically created by ViewModels (recommended), and their primitive outputs are used to build ViewData. +- Display Object instances MUST NOT cross any serialization boundary (RSC boundary, network, storage). + +#### 2.4.1 Display Objects are Frontend Value Objects (strict definition) + +Treat Display Objects like Domain Value Objects, but for the **presentation layer**. + +Display Objects are: + +- **Class-based** +- **Immutable** +- **Small** (one concept per object) +- **Deterministic** (same input -> same output everywhere) +- **Side-effect free** + +Display Objects are NOT: + +- utility modules of exported functions +- global lookup tables exported for ad hoc access +- a place to hide page logic + +Why strict class-based? + +- Naming: an object name communicates the concept (example: `MoneyDisplay`, `CountryDisplay`, `MonthYearDisplay`) +- Encapsulation: invariants and normalization live in one constructor path +- Reuse: ViewModels can share them without duplicating logic + +#### 2.4.2 Allowed responsibilities + +Display Objects MAY: + +- format raw values into **display strings** (date, number, money) +- map codes -> labels/icons/style tokens +- expose variants explicitly (for example `asShortLabel()`, `asLongLabel()`) + +Display Objects MUST NOT: + +- contain business rules (those live in `apps/api`) +- validate domain invariants +- call network or storage +- depend on framework runtime (React, Next.js) +- depend on runtime locale/timezone formatting APIs (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) โ€œDeterministic formatting requirement for Display Objectsโ€) + +#### 2.4.3 Strict API shape + +Each Display Object class MUST: + +- have a single responsibility +- accept only primitives in its constructor (or static constructor) +- expose only primitive outputs (strings/numbers/booleans) + +Recommended pattern: + +- `private constructor(...)` +- `static fromX(value: ...)` factory for normalization +- instance methods like `toString()`, `label()`, `cssClassToken()`, `ariaLabel()` + +Non-negotiable: no exported free functions as the primary API. + +#### 2.4.4 Serialization boundary rule + +Display Objects MUST NEVER appear in: + +- Page DTOs crossing server -> client +- ViewData passed into Templates + +Only the Display Objectโ€™s primitive outputs may be copied into ViewData. + +## 3) ViewData for Templates (strict) + +Templates MUST render **ViewData**, not ViewModels. + +Definitions: + +- **Page DTO**: the serializable data returned by a server query and passed across the RSC boundary. +- **ViewModel**: client-only object that encapsulates view-only derivations and composes Display Objects. +- **ViewData**: a JSON-serializable, template-ready data structure that Templates render. + +Rules: + +1) ViewData MUST be JSON-serializable (same restrictions as Page DTO in [Section 3](plans/nextjs-rsc-viewmodels-concept.md:83)). +2) ViewData MUST contain only values ready for display. Templates MUST NOT format. +3) ViewData MUST be produced in client code: + - Initial render: from Page DTO (SSR-safe) + - Post-hydration: from ViewModel (client-only) +4) Formatting implementation MUST live in Display Objects in `apps/website/lib/display-objects/*`. +5) ViewData MUST NOT contain Display Object instances. ViewData contains only primitives (mostly strings) that were produced by Display Objects. + +Rationale: Display Objects are classes/value objects and are not safe to serialize across the Next.js Client Component boundary. They are used as deterministic formatters/mappers, but only their primitive outputs may enter ViewData. + +## 4) DTO boundary (RSC boundary) + +### 4.1 Page DTO definition + +The ONLY data that may cross from a server component into a client component is a Page DTO. + +Page DTOs MUST: + +- be JSON-serializable +- contain only primitives, arrays, and plain objects +- use ISO strings for timestamps +- use `null` for missing values (no `undefined`) + +Page DTOs MUST NOT contain: + +- ViewModels +- Display Objects +- `Date` +- `Map` / `Set` +- functions + +### 4.2 DTO types + +When a ViewModel already defines its raw data type, that raw data type IS the Page DTO. + +Example (profile): [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93). + +Dashboard MUST define an equivalent `DashboardOverviewViewModelData` (or analogous) next to the dashboard ViewModel. + +## 4.3 Deterministic formatting requirement for Display Objects + +Because ViewData is rendered during SSR and re-rendered after hydration, any formatting used to produce ViewData MUST be deterministic across Node and the browser. + +Therefore Display Objects MUST NOT use locale-dependent runtime formatting APIs, including: + +- `Intl.*` +- `Date.toLocaleString()` / `Date.toLocaleDateString()` + +This policy is strict and global for `apps/website`: `Intl.*` and `toLocale*` are forbidden everywhere in rendering codepaths (pages, templates, components, view models, display objects). If formatting is required, it MUST be implemented deterministically via explicit algorithms/lookup tables. + +Display Objects MAY use: + +- explicit lookup tables (example: month names) +- numeric formatting implemented without locale APIs + +This is the only way to guarantee identical SSR and client outputs. + +## 4.4 ViewData and Display Objects (serialization rule) + +Display Objects are classes/value objects. They are NOT guaranteed to be serializable. + +Therefore: + +- ViewData MUST NOT contain Display Object instances. +- ViewData contains only primitives (usually strings) produced by Display Objects. + +## 5) Query result contract (no `null`) + +Rationale: returning `null` from server-side fetch orchestration conflates โ€œnot foundโ€, โ€œunauthorized/redirectโ€, and โ€œunexpected errorโ€. This makes route behavior ambiguous and encourages pages to implement policy via ad hoc checks. + +Therefore, this concept forbids `null` as a query outcome. + +### 5.1 Mandatory `PageQueryResult` discriminated union + +Every server query class (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:42)) MUST return a discriminated union: + +- `ok` with `{ dto: PageDTO }` +- `notFound` +- `redirect` with `{ to: string }` +- `error` with `{ errorId: string }` (and logging done server-side) + +Pages MUST switch on this result and decide: + +- `notFound` -> [`notFound()`](apps/website/app/dashboard/page.tsx:1) +- `redirect` -> [`redirect()`](apps/website/app/leaderboards/page.tsx:7) +- `error` -> throw to Next.js error boundary or render route error boundary + +### 5.2 `PageDataFetcher` usage rule + +The current [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) and [`PageDataFetcher.fetchManual()`](apps/website/lib/page/PageDataFetcher.ts:36) return `null` on error. + +In the new architecture: + +- Server page modules MUST NOT consume `null`-returning APIs for route decisions. +- Server query classes MUST wrap any usage of [`PageDataFetcher`](apps/website/lib/page/PageDataFetcher.ts:9) into `PageQueryResult` and MUST NOT leak `null` upward. + +If `PageDataFetcher` is refactored later, its single-fetch methods MUST return a result type (similar to [`FetchResult`](apps/website/lib/page/PageDataFetcher.ts:3)) rather than `null`. + +## 5.3 DI usage (strict) + +This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1). + +Rules: + +1) `page.tsx` modules MUST NOT access the DI container directly (no [`ContainerManager.getInstance()`](apps/website/lib/di/container.ts:67)). +2) Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests. +3) Because [`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container. +4) Client components MAY use DI via `ContainerProvider` + hooks like [`useInject`](apps/website/lib/di/hooks/useInject.ts:1). + +Non-negotiable: no stateful service instances may be shared across requests via the singleton container. + +## 6) Required component shape per route + +Every route MUST be structured as: + +1) `page.tsx` (Server Component) +2) `*PageClient.tsx` (Client Component) +3) `*Template.tsx` (pure stateless UI) + +### 6.1 Server `page.tsx` + +Server `page.tsx` MUST: + +- call the route query class +- pass only the Page DTO into the client component + +Server `page.tsx` MUST NOT: + +- import from `apps/website/lib/view-models/*` +- instantiate ViewModels + +### 6.2 Client `*PageClient.tsx` + +Client `*PageClient.tsx` MUST: + +- start with `'use client'` +- accept the Page DTO as prop +- render the Template with **ViewData** + +Client `*PageClient.tsx` MUST implement a two-phase render: + +1) Initial render (SSR-safe): + - MUST NOT instantiate ViewModels + - MUST create initial ViewData directly from Page DTO + - MUST render Template with initial ViewData + +2) Post-hydration (client-only): + - MUST instantiate the ViewModel + - MUST derive enhanced ViewData from the ViewModel (using Display Objects) + - MUST re-render Template with enhanced ViewData + +## 6.4 Initial SSR ViewData policy (non-optional) + +Initial SSR ViewData MUST be **fully populated**, but only using deterministic formatting as defined in [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) under โ€œDeterministic formatting requirement for Display Objectsโ€. + +This yields: + +- SSR delivers meaningful content (no skeleton-only pages) +- Hydration stays stable because the same deterministic Display Objects run on both SSR and client + +### 6.3 `*Template.tsx` (pure UI) + +Templates MUST: + +- be pure and stateless +- accept `ViewData` only +- contain no formatting logic +- contain no filtering/sorting logic + +Templates MAY be imported by server or client modules. + +Templates MUST NOT import: + +- `apps/website/lib/view-models/*` +- `apps/website/lib/display-objects/*` + +## 7) Hydration safety (strict) + +Hydration mismatch warnings are treated as build-breaking defects. + +Forbidden in any `page.tsx` module under [apps/website/app](apps/website/app/page.tsx:1): + +- [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430) +- any other locale/timezone dependent formatting +- any non-determinism (`Math.random`, `Date.now`) during render + +All human-readable formatting MUST be done via Display Objects in the client. + +Additionally forbidden anywhere Display Objects are executed to produce ViewData: + +- `Intl.*` +- `Date.toLocaleString()` / `Date.toLocaleDateString()` + +## 8) Guardrails (mandatory) + +### 8.1 Boundary tests + +Extend [apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts](apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts:1) with tests that FAIL when: + +- any `apps/website/app/**/page.tsx` imports from `apps/website/lib/view-models/*` +- any `apps/website/app/**/page.tsx` contains banned formatting calls (including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430)) +- any `apps/website/app/**/page.tsx` contains sorting/filtering logic (`sort`, `filter`, `reduce`) outside trivial null checks + +Add template boundary tests that FAIL when: + +- any `apps/website/templates/**` imports from `apps/website/lib/view-models/*` +- any `apps/website/templates/**` imports from `apps/website/lib/display-objects/*` + +### 8.2 ESLint restrictions + +Add ESLint restrictions that enforce the same rules at authoring time. + +## 9) Migration steps (dashboard first, then profile) + +### 9.1 Dashboard + +Starting point: [apps/website/app/dashboard/page.tsx](apps/website/app/dashboard/page.tsx:1). + +Steps: + +1) Introduce `DashboardPageQuery` under `apps/website/lib/page-queries/*` that returns a Dashboard Page DTO. +2) Change the dashboard server page to call the query and render `DashboardPageClient`. +3) Create `DashboardPageClient` as client module: + - Initial render: builds ViewData from DTO and renders [`DashboardTemplate`](apps/website/templates/DashboardTemplate.tsx:1). + - Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template. +4) Ensure any display formatting is implemented as Display Objects. + +### 9.2 Profile + +Starting point: [apps/website/app/profile/page.tsx](apps/website/app/profile/page.tsx:1). + +Steps: + +1) Move all helper logic out of the page module into a template and Display Objects. +2) Make profile `page.tsx` a server component that calls a query class returning [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93). +3) Create `ProfilePageClient` as client module: + - Initial render: builds ViewData from DTO and renders the template. + - Post-hydration: instantiates [`DriverProfileViewModel`](apps/website/lib/view-models/DriverProfileViewModel.ts:108), builds enhanced ViewData, re-renders template. +4) Remove all formatting in the page module, including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430). + +## 10) Acceptance criteria + +1) No hydration mismatch warnings on dashboard and profile. +2) No ViewModel instantiation in server modules. +3) No formatting/sorting/filtering logic in any module under [apps/website/app](apps/website/app/page.tsx:1). +4) All formatting is encapsulated by Display Objects under `apps/website/lib/display-objects/*`.