diff --git a/apps/api/src/config/feature-loader.ts b/apps/api/src/config/feature-loader.ts index 44430a058..fdd7f6d44 100644 --- a/apps/api/src/config/feature-loader.ts +++ b/apps/api/src/config/feature-loader.ts @@ -19,7 +19,7 @@ const DEFAULT_CONFIG_PATH = 'apps/api/src/config/features.config.ts'; * Output: { 'sponsors.portal': 'enabled' } */ function flattenFeatures( - config: Record, + config: Record, prefix: string = '' ): FlattenedFeatures { const flattened: FlattenedFeatures = {}; @@ -29,7 +29,7 @@ function flattenFeatures( if (typeof value === 'object' && value !== null && !Array.isArray(value)) { // Recursively flatten nested objects - Object.assign(flattened, flattenFeatures(value, fullKey)); + Object.assign(flattened, flattenFeatures(value as Record, fullKey)); } else if (isFeatureState(value)) { // Assign feature state flattened[fullKey] = value; diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index ac801757b..ecabd78ee 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from '@/lib/auth/AuthContext'; import { FeatureFlagService } from '@/lib/feature/FeatureFlagService'; import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider'; import { ContainerProvider } from '@/lib/di/providers/ContainerProvider'; +import { QueryClientProvider } from '@/lib/providers/QueryClientProvider'; import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler'; import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; import { Metadata, Viewport } from 'next'; @@ -81,39 +82,41 @@ export default async function RootLayout({ - - - - - -
-
-
-
- - GridPilot - -

- Making league racing less chaotic -

+ + + + + + +
+
+
+
+ + GridPilot + +

+ Making league racing less chaotic +

+
-
-
-
{children}
- {/* Development Tools */} - {process.env.NODE_ENV === 'development' && } -
-
-
-
+ +
{children}
+ {/* Development Tools */} + {process.env.NODE_ENV === 'development' && } + + + + +
diff --git a/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx b/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx new file mode 100644 index 000000000..0a155d27e --- /dev/null +++ b/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate'; +import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; + +interface LeaderboardsPageData { + drivers: DriverLeaderboardViewModel | null; + teams: TeamSummaryViewModel[] | null; +} + +export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) { + const router = useRouter(); + + if (!data || (!data.drivers && !data.teams)) { + return null; + } + + const drivers = data.drivers?.drivers || []; + const teams = data.teams || []; + + const handleDriverClick = (driverId: string) => { + router.push(`/drivers/${driverId}`); + }; + + const handleTeamClick = (teamId: string) => { + router.push(`/teams/${teamId}`); + }; + + const handleNavigateToDrivers = () => { + router.push('/leaderboards/drivers'); + }; + + const handleNavigateToTeams = () => { + router.push('/teams/leaderboard'); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx b/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx new file mode 100644 index 000000000..995bd0853 --- /dev/null +++ b/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate'; +import { useState } from 'react'; +import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; + +type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; +type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; + +export function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) { + const router = useRouter(); + + // Client-side state for filtering and sorting + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); + const [sortBy, setSortBy] = useState('rank'); + const [showFilters, setShowFilters] = useState(false); + + if (!data || !data.drivers) { + return null; + } + + const handleDriverClick = (driverId: string) => { + if (driverId.startsWith('demo-')) return; + router.push(`/drivers/${driverId}`); + }; + + const handleBackToLeaderboards = () => { + router.push('/leaderboards'); + }; + + return ( + setShowFilters(!showFilters)} + onDriverClick={handleDriverClick} + onBackToLeaderboards={handleBackToLeaderboards} + /> + ); +} \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index e1a6376d3..94a6819fe 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -1,62 +1,11 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate'; import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DriverService } from '@/lib/services/drivers/DriverService'; import { Users } from 'lucide-react'; -import { redirect , useRouter } from 'next/navigation'; +import { redirect } from 'next/navigation'; import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; -import { useState } from 'react'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; -type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; - -// ============================================================================ -// WRAPPER COMPONENT (Client-side state management) -// ============================================================================ - -function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) { - const router = useRouter(); - - // Client-side state for filtering and sorting - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); - const [sortBy, setSortBy] = useState('rank'); - const [showFilters, setShowFilters] = useState(false); - - if (!data || !data.drivers) { - return null; - } - - const handleDriverClick = (driverId: string) => { - if (driverId.startsWith('demo-')) return; - router.push(`/drivers/${driverId}`); - }; - - const handleBackToLeaderboards = () => { - router.push('/leaderboards'); - }; - - return ( - setShowFilters(!showFilters)} - onDriverClick={handleDriverClick} - onBackToLeaderboards={handleBackToLeaderboards} - /> - ); -} +import { DriverRankingsPageWrapper } from './DriverRankingsPageWrapper'; // ============================================================================ // MAIN PAGE COMPONENT diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index 0e353e0e5..8ca78249e 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -1,13 +1,13 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate'; import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DriverService } from '@/lib/services/drivers/DriverService'; import { TeamService } from '@/lib/services/teams/TeamService'; import { Trophy } from 'lucide-react'; -import { redirect , useRouter } from 'next/navigation'; +import { redirect } from 'next/navigation'; import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper'; // ============================================================================ // TYPES @@ -18,48 +18,6 @@ interface LeaderboardsPageData { teams: TeamSummaryViewModel[] | null; } -// ============================================================================ -// WRAPPER COMPONENT -// ============================================================================ - -function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) { - const router = useRouter(); - - if (!data || (!data.drivers && !data.teams)) { - return null; - } - - const drivers = data.drivers?.drivers || []; - const teams = data.teams || []; - - const handleDriverClick = (driverId: string) => { - router.push(`/drivers/${driverId}`); - }; - - const handleTeamClick = (teamId: string) => { - router.push(`/teams/${teamId}`); - }; - - const handleNavigateToDrivers = () => { - router.push('/leaderboards/drivers'); - }; - - const handleNavigateToTeams = () => { - router.push('/teams/leaderboard'); - }; - - return ( - - ); -} - // ============================================================================ // MAIN PAGE COMPONENT // ============================================================================ diff --git a/apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx b/apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx new file mode 100644 index 000000000..ac7729ec2 --- /dev/null +++ b/apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate'; +import { useState } from 'react'; +import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; + +type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; +type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; + +export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) { + const router = useRouter(); + + // Client-side state for filtering and sorting + const [searchQuery, setSearchQuery] = useState(''); + const [filterLevel, setFilterLevel] = useState('all'); + const [sortBy, setSortBy] = useState('rating'); + + if (!data || data.length === 0) { + return null; + } + + const handleTeamClick = (teamId: string) => { + router.push(`/teams/${teamId}`); + }; + + const handleBackToTeams = () => { + router.push('/teams'); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index 91ed933c3..647c14a1a 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -1,58 +1,11 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate'; import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens'; import { TeamService } from '@/lib/services/teams/TeamService'; import { Trophy } from 'lucide-react'; -import { redirect , useRouter } from 'next/navigation'; +import { redirect } from 'next/navigation'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; -import { useState } from 'react'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; -type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; - -// ============================================================================ -// WRAPPER COMPONENT (Client-side state management) -// ============================================================================ - -function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) { - const router = useRouter(); - - // Client-side state for filtering and sorting - const [searchQuery, setSearchQuery] = useState(''); - const [filterLevel, setFilterLevel] = useState('all'); - const [sortBy, setSortBy] = useState('rating'); - - if (!data || data.length === 0) { - return null; - } - - const handleTeamClick = (teamId: string) => { - router.push(`/teams/${teamId}`); - }; - - const handleBackToTeams = () => { - router.push('/teams'); - }; - - return ( - - ); -} +import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper'; // ============================================================================ // MAIN PAGE COMPONENT diff --git a/apps/website/lib/di/container.ts b/apps/website/lib/di/container.ts index 33277ec7c..af5b651ff 100644 --- a/apps/website/lib/di/container.ts +++ b/apps/website/lib/di/container.ts @@ -2,6 +2,7 @@ import { Container } from 'inversify'; // Module imports import { ApiModule } from './modules/api.module'; +import { AuthModule } from './modules/auth.module'; import { CoreModule } from './modules/core.module'; import { DashboardModule } from './modules/dashboard.module'; import { DriverModule } from './modules/driver.module'; @@ -23,6 +24,7 @@ export function createContainer(): Container { container.load( CoreModule, ApiModule, + AuthModule, LeagueModule, DriverModule, TeamModule, diff --git a/apps/website/lib/di/index.ts b/apps/website/lib/di/index.ts index e9503a610..83bf9407d 100644 --- a/apps/website/lib/di/index.ts +++ b/apps/website/lib/di/index.ts @@ -13,6 +13,7 @@ export * from './providers/ContainerProvider'; // Modules export * from './modules/analytics.module'; export * from './modules/api.module'; +export * from './modules/auth.module'; export * from './modules/core.module'; export * from './modules/dashboard.module'; export * from './modules/driver.module'; diff --git a/apps/website/lib/di/modules/auth.module.ts b/apps/website/lib/di/modules/auth.module.ts new file mode 100644 index 000000000..c4a109c56 --- /dev/null +++ b/apps/website/lib/di/modules/auth.module.ts @@ -0,0 +1,30 @@ +import { ContainerModule } from 'inversify'; +import { AuthService } from '../../services/auth/AuthService'; +import { SessionService } from '../../services/auth/SessionService'; +import { AuthApiClient } from '../../api/auth/AuthApiClient'; + +import { + AUTH_SERVICE_TOKEN, + SESSION_SERVICE_TOKEN, + AUTH_API_CLIENT_TOKEN +} from '../tokens'; + +export const AuthModule = new ContainerModule((options) => { + const bind = options.bind; + + // Session Service + bind(SESSION_SERVICE_TOKEN) + .toDynamicValue((ctx) => { + const authApiClient = ctx.get(AUTH_API_CLIENT_TOKEN); + return new SessionService(authApiClient); + }) + .inSingletonScope(); + + // Auth Service + bind(AUTH_SERVICE_TOKEN) + .toDynamicValue((ctx) => { + const authApiClient = ctx.get(AUTH_API_CLIENT_TOKEN); + return new AuthService(authApiClient); + }) + .inSingletonScope(); +}); diff --git a/apps/website/lib/di/modules/league.module.ts b/apps/website/lib/di/modules/league.module.ts index d285e0aec..98825d979 100644 --- a/apps/website/lib/di/modules/league.module.ts +++ b/apps/website/lib/di/modules/league.module.ts @@ -91,6 +91,9 @@ export const LeagueModule = new ContainerModule((options) => { // League Membership Service bind(LEAGUE_MEMBERSHIP_SERVICE_TOKEN) - .to(LeagueMembershipService) + .toDynamicValue((ctx) => { + const leagueApiClient = ctx.get(LEAGUE_API_CLIENT_TOKEN); + return new LeagueMembershipService(leagueApiClient); + }) .inSingletonScope(); }); diff --git a/apps/website/lib/infrastructure/logging/ConsoleLogger.ts b/apps/website/lib/infrastructure/logging/ConsoleLogger.ts index 9e657207f..6ae860f03 100644 --- a/apps/website/lib/infrastructure/logging/ConsoleLogger.ts +++ b/apps/website/lib/infrastructure/logging/ConsoleLogger.ts @@ -35,14 +35,29 @@ export class ConsoleLogger implements Logger { const emoji = this.EMOJIS[level]; const prefix = this.PREFIXES[level]; - console.groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`); + // Edge runtime doesn't support console.groupCollapsed/groupEnd + // Fallback to simple logging for compatibility + const supportsGrouping = typeof console.groupCollapsed === 'function' && typeof console.groupEnd === 'function'; + + if (supportsGrouping) { + // Safe to call - we've verified both functions exist + (console as any).groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`); + } else { + // Simple format for edge runtime + console.log(`${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`); + } console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString()); console.log(`%cSource:`, 'color: #666; font-weight: bold;', source); if (context) { console.log(`%cContext:`, 'color: #666; font-weight: bold;'); - console.dir(context, { depth: 3, colors: true }); + // console.dir may not be available in edge runtime + if (typeof console.dir === 'function') { + console.dir(context, { depth: 3, colors: true }); + } else { + console.log(JSON.stringify(context, null, 2)); + } } if (error) { @@ -56,7 +71,10 @@ export class ConsoleLogger implements Logger { } } - console.groupEnd(); + if (supportsGrouping) { + // Safe to call - we've verified the function exists + (console as any).groupEnd(); + } } debug(message: string, context?: unknown): void { diff --git a/apps/website/lib/providers/QueryClientProvider.tsx b/apps/website/lib/providers/QueryClientProvider.tsx new file mode 100644 index 000000000..c23e04296 --- /dev/null +++ b/apps/website/lib/providers/QueryClientProvider.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { QueryClient, QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +interface QueryClientProviderProps { + children: ReactNode; +} + +/** + * Provides React Query client to the application + * + * Must wrap any components that use React Query hooks (useQuery, useMutation, etc.) + * Creates a new QueryClient instance per component tree to avoid state sharing + */ +export function QueryClientProvider({ children }: QueryClientProviderProps) { + // Create a new QueryClient instance for each component tree + // This prevents state sharing between different renders + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // Disable automatic refetching in production for better performance + refetchOnWindowFocus: process.env.NODE_ENV === 'development', + refetchOnReconnect: true, + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + mutations: { + retry: 0, + }, + }, + }) + ); + + return ( + + {children} + + ); +} diff --git a/docs/FEATURE_ARCHITECTURE.md b/docs/FEATURE_ARCHITECTURE.md new file mode 100644 index 000000000..a74cb7822 --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE.md @@ -0,0 +1,225 @@ +# Feature Architecture: Modes vs Feature Flags + +## The Problem + +Your current system has two overlapping concepts that conflict: +- **Feature Flags** (`features.config.ts`) - Controls individual features +- **App Modes** (`NEXT_PUBLIC_GRIDPILOT_MODE`) - Controls overall platform visibility + +This creates confusion because: +1. Development shows only landing page but feature config says everything is enabled +2. It's unclear which system controls what +3. Teams don't know when to use mode vs feature flag + +## The Solution: Clear Separation + +### **Two-Tier System** + +``` +┌─────────────────────────────────────────┐ +│ APP MODE (Tier 1) │ +│ Controls WHAT the platform shows │ +│ - pre-launch: Landing page only │ +│ - alpha: Full platform access │ +│ - beta: Production-ready features │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ FEATURE FLAGS (Tier 2) │ +│ Controls WHICH features are enabled │ +│ - Individual feature toggles │ +│ - Rollout control │ +│ - A/B testing │ +└─────────────────────────────────────────┘ +``` + +### **Mode = Platform Scope** + +**App Mode defines what the entire platform can do:** + +- **`pre-launch`**: "We're not ready yet" + - Shows: Landing page, Discord CTA, FAQ + - Hides: All navigation, dashboard, leagues, teams, races + - Purpose: Marketing/teaser phase + +- **`alpha`**: "Early access for testers" + - Shows: Everything + alpha badges + - Purpose: Internal testing, early adopters + - All features enabled by default + +- **`beta`**: "Public beta" + - Shows: Production features only + - Purpose: Gradual rollout to real users + - Features controlled individually + +### **Feature Flags = Feature Control** + +**Feature flags control individual features within a mode:** + +```typescript +// In alpha mode, all features are ON by default +// But you can still disable specific ones for testing +{ + "platform.dashboard": "enabled", + "platform.leagues": "enabled", + "platform.teams": "disabled", // Testing without teams + "sponsors.portal": "enabled", + "admin.dashboard": "enabled" +} +``` + +## Simple Mental Model + +### **For Developers: "The Restaurant Analogy"** + +``` +APP MODE = Restaurant State +├── "Closed" (pre-launch) → Only show entrance/menu +├── "Soft Opening" (alpha) → Full menu, everything available +└── "Grand Opening" (beta) → Full menu, but some items may be 86'd + +FEATURE FLAGS = Menu Items +├── Each dish can be: Available / 86'd / Coming Soon +├── Works within whatever restaurant state you're in +└── Lets you control individual items precisely +``` + +### **Decision Tree** + +``` +Question: "What should I use?" +│ +├─ "Is the platform ready for ANY users?" +│ ├─ No → Use APP MODE = pre-launch +│ └─ Yes → Continue... +│ +├─ "Are we in testing or production?" +│ ├─ Testing → Use APP MODE = alpha +│ └─ Production → Use APP MODE = beta +│ +└─ "Do I need to control a specific feature?" + └─ Yes → Use FEATURE FLAGS (regardless of mode) +``` + +## Implementation Rules + +### **Rule 1: Mode Controls Visibility** +```typescript +// ❌ WRONG: Using feature flags to hide entire platform +{ + "platform": { + "dashboard": "disabled", + "leagues": "disabled", + "teams": "disabled" + } +} + +// ✅ CORRECT: Use mode for platform-wide visibility +// NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch +``` + +### **Rule 2: Feature Flags Control Granularity** +```typescript +// ✅ CORRECT: Feature flags for fine-grained control +{ + "platform": { + "dashboard": "enabled", + "leagues": "enabled", + "teams": "enabled", // But specific team features... + "teams.create": "disabled", // ...can be toggled + "teams.delete": "coming_soon" + } +} +``` + +### **Rule 3: Alpha Mode = Auto-Enable** +```typescript +// In alpha mode, ALL features are enabled automatically +// Feature flags can only DISABLE, not enable +// This eliminates configuration complexity + +// Feature flag service in alpha mode: +if (mode === 'alpha') { + return new FeatureFlagService([ + 'all', 'features', 'enabled', 'by', 'default' + ]); +} +``` + +## Migration Path + +### **Current State (Confusing)** +``` +Development: +- Mode: pre-launch (default) +- Features: All enabled in config +- Result: Shows landing page only ❌ + +Why? Because mode overrides feature config +``` + +### **Target State (Clear)** +``` +Development: +- Mode: alpha (explicit) +- Features: All auto-enabled +- Result: Full platform with alpha badges ✅ + +Production: +- Mode: beta +- Features: Controlled individually +- Result: Gradual rollout ✅ +``` + +## Configuration Examples + +### **Simple Mode Config** +```typescript +// apps/website/.env.development +NEXT_PUBLIC_GRIDPILOT_MODE=alpha + +// apps/website/.env.production +NEXT_PUBLIC_GRIDPILOT_MODE=beta +``` + +### **Feature Flags (Only When Needed)** +```typescript +// Only override defaults when necessary +// apps/api/src/config/features.config.ts +{ + production: { + platform: { + dashboard: 'enabled', + leagues: 'enabled', + teams: 'enabled', + races: 'enabled', + leaderboards: 'enabled' + }, + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'disabled' // Not ready yet + } + } +} +``` + +## Benefits + +1. **No Confusion**: Clear separation of concerns +2. **Less Config**: Alpha mode needs zero feature config +3. **Easy Onboarding**: New devs just set mode=alpha +4. **Powerful Control**: Feature flags still available when needed +5. **Backward Compatible**: Existing code works with new concept + +## Quick Reference + +| Scenario | Mode | Feature Flags | Result | +|----------|------|---------------|--------| +| Local dev | `alpha` | None needed | Full platform | +| CI/CD tests | `alpha` | None needed | Full platform | +| Staging | `beta` | Some disabled | Controlled rollout | +| Production | `beta` | Gradual enable | Public launch | +| Marketing site | `pre-launch` | N/A | Landing page only | + +This eliminates the contradiction and gives you clear power: **Mode for scope, flags for granularity.** \ No newline at end of file diff --git a/tests/e2e/website/website-pages.e2e.test.ts b/tests/e2e/website/website-pages.e2e.test.ts index b073bd628..52c36cedd 100644 --- a/tests/e2e/website/website-pages.e2e.test.ts +++ b/tests/e2e/website/website-pages.e2e.test.ts @@ -205,16 +205,88 @@ test.describe('Website Pages - TypeORM Integration', () => { !msg.includes('connection refused') && !msg.includes('failed to load resource') && !msg.includes('network error') && - !msg.includes('cors') && - !msg.includes('api'); + !msg.includes('cors'); + }); + + // Check for critical runtime errors that should never occur + const criticalErrors = errors.filter(error => { + const msg = error.message.toLowerCase(); + return msg.includes('no queryclient set') || + msg.includes('use queryclientprovider') || + msg.includes('console.groupcollapsed is not a function') || + msg.includes('console.groupend is not a function'); }); if (unexpectedErrors.length > 0) { console.log(`[TEST DEBUG] Unexpected errors on ${path}:`, unexpectedErrors); } - // Allow some errors in test environment due to network/API issues - expect(unexpectedErrors.length).toBeLessThanOrEqual(0); + if (criticalErrors.length > 0) { + console.log(`[TEST DEBUG] CRITICAL errors on ${path}:`, criticalErrors); + throw new Error(`Critical runtime errors on ${path}: ${JSON.stringify(criticalErrors)}`); + } + + // Fail on any unexpected errors including DI binding failures + expect(unexpectedErrors.length).toBe(0); + } + }); + + test('detect DI binding failures and missing metadata on boot', async ({ page }) => { + // Test critical routes that would trigger DI container creation + const criticalRoutes = [ + '/leagues', + '/dashboard', + '/teams', + '/drivers', + '/races', + '/leaderboards' + ]; + + for (const path of criticalRoutes) { + const capture = new ConsoleErrorCapture(page); + + const response = await page.goto(`${WEBSITE_BASE_URL}${path}`); + await page.waitForTimeout(500); + + // Check for 500 errors + const status = response?.status(); + if (status === 500) { + console.log(`[TEST DEBUG] 500 error on ${path}`); + const bodyText = await page.textContent('body'); + console.log(`[TEST DEBUG] Body content: ${bodyText?.substring(0, 500)}`); + } + + // Check for DI-related errors in console + const errors = capture.getErrors(); + const diErrors = errors.filter(error => { + const msg = error.message.toLowerCase(); + return msg.includes('binding') || + msg.includes('metadata') || + msg.includes('inversify') || + msg.includes('symbol') || + msg.includes('no binding') || + msg.includes('not bound'); + }); + + // Check for React Query provider errors + const queryClientErrors = errors.filter(error => { + const msg = error.message.toLowerCase(); + return msg.includes('no queryclient set') || + msg.includes('use queryclientprovider'); + }); + + if (diErrors.length > 0) { + console.log(`[TEST DEBUG] DI errors on ${path}:`, diErrors); + } + + if (queryClientErrors.length > 0) { + console.log(`[TEST DEBUG] QueryClient errors on ${path}:`, queryClientErrors); + throw new Error(`QueryClient provider missing on ${path}: ${JSON.stringify(queryClientErrors)}`); + } + + // Fail on DI errors or 500 status + expect(diErrors.length).toBe(0); + expect(status).not.toBe(500); } }); diff --git a/tests/integration/website-di-container.test.ts b/tests/integration/website-di-container.test.ts new file mode 100644 index 000000000..c4a349db0 --- /dev/null +++ b/tests/integration/website-di-container.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createContainer } from '../../apps/website/lib/di/container'; +import { + SESSION_SERVICE_TOKEN, + LEAGUE_MEMBERSHIP_SERVICE_TOKEN, + LEAGUE_SERVICE_TOKEN, + AUTH_SERVICE_TOKEN, + DRIVER_SERVICE_TOKEN, + TEAM_SERVICE_TOKEN, + RACE_SERVICE_TOKEN, + DASHBOARD_SERVICE_TOKEN, + LOGGER_TOKEN, + CONFIG_TOKEN, + LEAGUE_API_CLIENT_TOKEN, + AUTH_API_CLIENT_TOKEN, + DRIVER_API_CLIENT_TOKEN, + TEAM_API_CLIENT_TOKEN, + RACE_API_CLIENT_TOKEN, +} from '../../apps/website/lib/di/tokens'; + +/** + * Integration test for website DI container + * + * This test verifies that all critical DI bindings are properly configured + * and that the container can resolve all required services without throwing + * binding errors or missing metadata errors. + * + * This is a fast, non-Playwright test that runs in CI to catch DI issues early. + */ +describe('Website DI Container Integration', () => { + let container: ReturnType; + let originalEnv: NodeJS.ProcessEnv; + + beforeAll(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Set up minimal environment for DI container to work + // The container needs API_BASE_URL to initialize + process.env.API_BASE_URL = 'http://localhost:3001'; + process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:3001'; + process.env.NODE_ENV = 'test'; + + // Create the container once for all tests + container = createContainer(); + }); + + afterAll(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('creates container successfully', () => { + expect(container).toBeDefined(); + expect(container).not.toBeNull(); + }); + + it('resolves core services without errors', () => { + // Core services that should always be available + expect(() => container.get(LOGGER_TOKEN)).not.toThrow(); + expect(() => container.get(CONFIG_TOKEN)).not.toThrow(); + + const logger = container.get(LOGGER_TOKEN); + const config = container.get(CONFIG_TOKEN); + + expect(logger).toBeDefined(); + expect(config).toBeDefined(); + }); + + it('resolves API clients without errors', () => { + // API clients that services depend on + const apiClients = [ + LEAGUE_API_CLIENT_TOKEN, + AUTH_API_CLIENT_TOKEN, + DRIVER_API_CLIENT_TOKEN, + TEAM_API_CLIENT_TOKEN, + RACE_API_CLIENT_TOKEN, + ]; + + for (const token of apiClients) { + expect(() => container.get(token)).not.toThrow(); + const client = container.get(token); + expect(client).toBeDefined(); + } + }); + + it('resolves auth services including SessionService (critical for Symbol(Service.Session))', () => { + // This specifically tests for the Symbol(Service.Session) binding issue + expect(() => container.get(SESSION_SERVICE_TOKEN)).not.toThrow(); + expect(() => container.get(AUTH_SERVICE_TOKEN)).not.toThrow(); + + const sessionService = container.get(SESSION_SERVICE_TOKEN); + const authService = container.get(AUTH_SERVICE_TOKEN); + + expect(sessionService).toBeDefined(); + expect(authService).toBeDefined(); + + // Verify the services have expected methods + expect(typeof sessionService.getSession).toBe('function'); + expect(typeof authService.login).toBe('function'); + }); + + it('resolves league services including LeagueMembershipService (critical for metadata)', () => { + // This specifically tests for the LeagueMembershipService metadata issue + expect(() => container.get(LEAGUE_SERVICE_TOKEN)).not.toThrow(); + expect(() => container.get(LEAGUE_MEMBERSHIP_SERVICE_TOKEN)).not.toThrow(); + + const leagueService = container.get(LEAGUE_SERVICE_TOKEN); + const membershipService = container.get(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); + + expect(leagueService).toBeDefined(); + expect(membershipService).toBeDefined(); + + // Verify the services have expected methods + expect(typeof leagueService.getAllLeagues).toBe('function'); + expect(typeof membershipService.getLeagueMemberships).toBe('function'); + }); + + it('resolves domain services without errors', () => { + // Test other critical domain services + expect(() => container.get(DRIVER_SERVICE_TOKEN)).not.toThrow(); + expect(() => container.get(TEAM_SERVICE_TOKEN)).not.toThrow(); + expect(() => container.get(RACE_SERVICE_TOKEN)).not.toThrow(); + expect(() => container.get(DASHBOARD_SERVICE_TOKEN)).not.toThrow(); + + const driverService = container.get(DRIVER_SERVICE_TOKEN); + const teamService = container.get(TEAM_SERVICE_TOKEN); + const raceService = container.get(RACE_SERVICE_TOKEN); + const dashboardService = container.get(DASHBOARD_SERVICE_TOKEN); + + expect(driverService).toBeDefined(); + expect(teamService).toBeDefined(); + expect(raceService).toBeDefined(); + expect(dashboardService).toBeDefined(); + }); + + it('resolves all services in a single operation (full container boot simulation)', () => { + // This simulates what happens when the website boots and needs multiple services + const tokens = [ + SESSION_SERVICE_TOKEN, + LEAGUE_MEMBERSHIP_SERVICE_TOKEN, + LEAGUE_SERVICE_TOKEN, + AUTH_SERVICE_TOKEN, + DRIVER_SERVICE_TOKEN, + TEAM_SERVICE_TOKEN, + RACE_SERVICE_TOKEN, + DASHBOARD_SERVICE_TOKEN, + LOGGER_TOKEN, + CONFIG_TOKEN, + ]; + + // Resolve all tokens - if any binding is missing or metadata is wrong, this will throw + const services = tokens.map(token => { + try { + return container.get(token); + } catch (error) { + throw new Error(`Failed to resolve token ${token.toString()}: ${error.message}`); + } + }); + + // Verify all services were resolved + expect(services.length).toBe(tokens.length); + services.forEach((service, index) => { + expect(service).toBeDefined(); + expect(service).not.toBeNull(); + }); + }); + + it('throws clear error for non-existent bindings', () => { + const fakeToken = Symbol.for('Non.Existent.Service'); + + expect(() => container.get(fakeToken)).toThrow(); + }); +});