fix issues
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,6 +82,7 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="antialiased overflow-x-hidden">
|
||||
<ContainerProvider>
|
||||
<QueryClientProvider>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider flags={enabledFlags}>
|
||||
<NotificationProvider>
|
||||
@@ -114,6 +116,7 @@ export default async function RootLayout({
|
||||
</NotificationProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ContainerProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
49
apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx
Normal file
49
apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
30
apps/website/lib/di/modules/auth.module.ts
Normal file
30
apps/website/lib/di/modules/auth.module.ts
Normal 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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 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 {
|
||||
|
||||
42
apps/website/lib/providers/QueryClientProvider.tsx
Normal file
42
apps/website/lib/providers/QueryClientProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
docs/FEATURE_ARCHITECTURE.md
Normal file
225
docs/FEATURE_ARCHITECTURE.md
Normal 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.**
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
174
tests/integration/website-di-container.test.ts
Normal file
174
tests/integration/website-di-container.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user