fix issues

This commit is contained in:
2026-01-07 16:20:19 +01:00
parent 3b3971e653
commit 1b63fa646c
17 changed files with 758 additions and 187 deletions

View File

@@ -19,7 +19,7 @@ const DEFAULT_CONFIG_PATH = 'apps/api/src/config/features.config.ts';
* Output: { 'sponsors.portal': 'enabled' }
*/
function flattenFeatures(
config: Record<string, any>,
config: Record<string, unknown>,
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<string, unknown>, fullKey));
} else if (isFeatureState(value)) {
// Assign feature state
flattened[fullKey] = value;

View File

@@ -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({
</head>
<body className="antialiased overflow-x-hidden">
<ContainerProvider>
<AuthProvider>
<FeatureFlagProvider flags={enabledFlags}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<p className="hidden sm:block text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
<QueryClientProvider>
<AuthProvider>
<FeatureFlagProvider flags={enabledFlags}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<p className="hidden sm:block text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
</div>
</div>
</div>
</div>
</header>
<div className="pt-16">{children}</div>
{/* Development Tools */}
{process.env.NODE_ENV === 'development' && <DevToolbar />}
</EnhancedErrorBoundary>
</NotificationProvider>
</FeatureFlagProvider>
</AuthProvider>
</header>
<div className="pt-16">{children}</div>
{/* Development Tools */}
{process.env.NODE_ENV === 'development' && <DevToolbar />}
</EnhancedErrorBoundary>
</NotificationProvider>
</FeatureFlagProvider>
</AuthProvider>
</QueryClientProvider>
</ContainerProvider>
</body>
</html>

View File

@@ -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 (
<LeaderboardsTemplate
drivers={drivers}
teams={teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
);
}

View File

@@ -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<SortBy>('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 (
<DriverRankingsTemplate
drivers={data.drivers}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}

View File

@@ -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<SortBy>('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 (
<DriverRankingsTemplate
drivers={data.drivers}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}
import { DriverRankingsPageWrapper } from './DriverRankingsPageWrapper';
// ============================================================================
// MAIN PAGE COMPONENT

View File

@@ -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 (
<LeaderboardsTemplate
drivers={drivers}
teams={teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================

View File

@@ -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<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!data || data.length === 0) {
return null;
}
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
return (
<TeamLeaderboardTemplate
teams={data}
searchQuery={searchQuery}
filterLevel={filterLevel}
sortBy={sortBy}
onSearchChange={setSearchQuery}
onFilterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}

View File

@@ -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<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!data || data.length === 0) {
return null;
}
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
return (
<TeamLeaderboardTemplate
teams={data}
searchQuery={searchQuery}
filterLevel={filterLevel}
sortBy={sortBy}
onSearchChange={setSearchQuery}
onFilterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
// ============================================================================
// MAIN PAGE COMPONENT

View File

@@ -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,

View File

@@ -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';

View File

@@ -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<SessionService>(SESSION_SERVICE_TOKEN)
.toDynamicValue((ctx) => {
const authApiClient = ctx.get<AuthApiClient>(AUTH_API_CLIENT_TOKEN);
return new SessionService(authApiClient);
})
.inSingletonScope();
// Auth Service
bind<AuthService>(AUTH_SERVICE_TOKEN)
.toDynamicValue((ctx) => {
const authApiClient = ctx.get<AuthApiClient>(AUTH_API_CLIENT_TOKEN);
return new AuthService(authApiClient);
})
.inSingletonScope();
});

View File

@@ -91,6 +91,9 @@ export const LeagueModule = new ContainerModule((options) => {
// League Membership Service
bind<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN)
.to(LeagueMembershipService)
.toDynamicValue((ctx) => {
const leagueApiClient = ctx.get<LeaguesApiClient>(LEAGUE_API_CLIENT_TOKEN);
return new LeagueMembershipService(leagueApiClient);
})
.inSingletonScope();
});

View File

@@ -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 {

View File

@@ -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 (
<TanstackQueryClientProvider client={queryClient}>
{children}
</TanstackQueryClientProvider>
);
}

View File

@@ -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.**

View File

@@ -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);
}
});

View File

@@ -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<typeof createContainer>;
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();
});
});