feature flags

This commit is contained in:
2026-01-07 22:05:53 +01:00
parent 1b63fa646c
commit 606b64cec7
530 changed files with 2092 additions and 2943 deletions

View File

@@ -132,6 +132,15 @@
}
}
},
"/auth/signup-sponsor": {
"post": {
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/dashboard/overview": {
"get": {
"responses": {
@@ -1449,6 +1458,9 @@
"type": "string",
"nullable": true
},
"companyId": {
"type": "string"
},
"role": {
"type": "string"
}
@@ -6013,6 +6025,29 @@
"displayName"
]
},
"SignupSponsorParamsDTO": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"displayName": {
"type": "string"
},
"companyName": {
"type": "string"
}
},
"required": [
"email",
"password",
"displayName",
"companyName"
]
},
"SponsorDashboardDTO": {
"type": "object",
"properties": {

View File

@@ -17,6 +17,7 @@ import { ProtestsModule } from './domain/protests/ProtestsModule';
import { RaceModule } from './domain/race/RaceModule';
import { SponsorModule } from './domain/sponsor/SponsorModule';
import { TeamModule } from './domain/team/TeamModule';
import { FeaturesModule } from './features/features.module';
import { getApiPersistence, getEnableBootstrap } from './env';
import { RequestContextMiddleware } from './shared/http/RequestContext';
@@ -46,6 +47,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
PaymentsModule,
PolicyModule,
AdminModule,
FeaturesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -27,8 +27,20 @@ describe('Feature Flag Configuration', () => {
expect(result.loadedFrom).toBe('config-file');
expect(result.features).toBeDefined();
// Core platform features (alpha mode - all enabled)
expect(result.features['platform.dashboard']).toBe('enabled');
expect(result.features['platform.leagues']).toBe('enabled');
// Sponsor features
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['sponsors.management']).toBe('enabled');
// Admin features
expect(result.features['admin.dashboard']).toBe('enabled');
// Beta features (development has them enabled for testing)
expect(result.features['beta.newUI']).toBe('enabled');
});
it('should load test config when NODE_ENV=test', async () => {
@@ -36,8 +48,18 @@ describe('Feature Flag Configuration', () => {
const result = await loadFeatureConfig();
expect(result.loadedFrom).toBe('config-file');
// Core platform features
expect(result.features['platform.dashboard']).toBe('enabled');
// Sponsor features
expect(result.features['sponsors.portal']).toBe('enabled');
// Admin features
expect(result.features['admin.dashboard']).toBe('enabled');
// Beta features (disabled in test)
expect(result.features['beta.newUI']).toBe('disabled');
});
it('should load production config when NODE_ENV=production', async () => {
@@ -45,11 +67,22 @@ describe('Feature Flag Configuration', () => {
const result = await loadFeatureConfig();
expect(result.loadedFrom).toBe('config-file');
// Core platform features (stable)
expect(result.features['platform.dashboard']).toBe('enabled');
// Sponsor features (gradual rollout - management disabled)
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['sponsors.management']).toBe('disabled');
// Admin features (analytics disabled)
expect(result.features['admin.dashboard']).toBe('enabled');
expect(result.features['admin.analytics']).toBe('disabled');
// Beta features (disabled in production)
expect(result.features['beta.newUI']).toBe('disabled');
});
it('should handle invalid environment', async () => {
process.env.NODE_ENV = 'invalid-env';
@@ -107,4 +140,24 @@ describe('Feature Flag Configuration', () => {
expect(result.features['admin.dashboard']).toBeDefined();
});
});
describe('Feature state differences', () => {
it('should correctly handle all feature states', async () => {
process.env.NODE_ENV = 'staging';
const result = await loadFeatureConfig();
// 'enabled' - fully available
expect(result.features['platform.dashboard']).toBe('enabled');
// 'disabled' - turned off completely
expect(result.features['beta.experimental']).toBe('hidden');
// 'coming_soon' - visible but not available
expect(result.features['sponsors.management']).toBe('coming_soon');
expect(result.features['beta.newUI']).toBe('coming_soon');
// 'hidden' - completely invisible
expect(result.features['beta.experimental']).toBe('hidden');
});
});
});

View File

@@ -2,6 +2,12 @@
* Feature Flag Configuration Types
*
* Provides type-safe configuration for feature flags across different environments
*
* FEATURE STATES:
* - 'enabled': Feature is fully available to users
* - 'disabled': Feature is turned off (not visible/functional)
* - 'coming_soon': Feature is visible but not yet available (shows "coming soon")
* - 'hidden': Feature is completely invisible (internal/experimental only)
*/
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';

View File

@@ -2,33 +2,121 @@ import { FeatureFlagConfig } from './feature-types';
/**
* Feature flag configuration for all environments
* This provides type safety, IntelliSense, and environment-specific settings
*
* ARCHITECTURE: API-Driven Features
* - All feature control comes from the API /features endpoint
* - FeatureFlagService fetches and caches features
* - Components use FeatureFlagService or ModeGuard for conditional rendering
*
* FEATURE STATES - DETAILED EXPLANATION:
*
* 'enabled' = Feature is fully available to users
* - Visible in UI
* - Fully functional
* - No restrictions
* - Example: "Users can create leagues"
*
* 'disabled' = Feature is turned off for everyone
* - Not visible in UI (or shown as unavailable)
* - Non-functional
* - Users cannot access it
* - Use when: Feature is broken, not ready, or intentionally removed
* - Example: "Sponsor management disabled due to bug"
*
* 'coming_soon' = Feature is visible but not yet available
* - Visible in UI with "Coming Soon" badge
* - Shows users what's coming
* - Builds anticipation
* - Use when: Feature is in development, users should know about it
* - Example: "New UI coming soon" - users see it but can't use it
*
* 'hidden' = Feature is completely invisible
* - Not shown in UI at all
* - No user knows it exists
* - Use when: Feature is experimental, internal-only, or not ready for ANY visibility
* - Example: "Experimental AI feature" - only devs know it exists
*
* DECISION TREE:
* - Should users see this feature exists?
* - NO → 'hidden' or 'disabled'
* - YES → Should they be able to use it?
* - NO → 'coming_soon'
* - YES → 'enabled'
*
* REAL-WORLD EXAMPLES:
* - 'enabled': Dashboard, leagues, teams (core features working)
* - 'disabled': Sponsor management (broken, don't show anything)
* - 'coming_soon': New UI redesign (users see "coming soon" banner)
* - 'hidden': Experimental chat feature (internal testing only)
*/
export const featureConfig: FeatureFlagConfig = {
// Development environment - features for local development
// Development environment - all features enabled for testing
development: {
// Core platform features
platform: {
dashboard: 'enabled',
leagues: 'enabled',
teams: 'enabled',
drivers: 'enabled',
races: 'enabled',
leaderboards: 'enabled',
},
// Authentication & onboarding
auth: {
signup: 'enabled',
login: 'enabled',
forgotPassword: 'enabled',
resetPassword: 'enabled',
},
onboarding: {
wizard: 'enabled',
},
// Sponsor features
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
campaigns: 'enabled',
billing: 'enabled',
},
// Admin features
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
// Beta features for testing
beta: {
newUI: 'enabled', // Enable new UI for testing
newUI: 'enabled',
experimental: 'coming_soon',
},
},
// Test environment - features for automated tests
// Test environment - all features enabled for automated tests
test: {
platform: {
dashboard: 'enabled',
leagues: 'enabled',
teams: 'enabled',
drivers: 'enabled',
races: 'enabled',
leaderboards: 'enabled',
},
auth: {
signup: 'enabled',
login: 'enabled',
forgotPassword: 'enabled',
resetPassword: 'enabled',
},
onboarding: {
wizard: 'enabled',
},
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
campaigns: 'enabled',
billing: 'enabled',
},
admin: {
dashboard: 'enabled',
@@ -41,18 +129,42 @@ export const featureConfig: FeatureFlagConfig = {
},
},
// Staging environment - features for pre-production testing
// Staging environment - controlled feature rollout
staging: {
// Core platform features
platform: {
dashboard: 'enabled',
leagues: 'enabled',
teams: 'enabled',
drivers: 'enabled',
races: 'enabled',
leaderboards: 'enabled',
},
// Authentication & onboarding
auth: {
signup: 'enabled',
login: 'enabled',
forgotPassword: 'enabled',
resetPassword: 'enabled',
},
onboarding: {
wizard: 'enabled',
},
// Sponsor features (gradual rollout)
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
management: 'coming_soon', // Ready for testing but not fully rolled out
campaigns: 'enabled',
billing: 'enabled',
},
// Admin features
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
// Beta features (controlled rollout)
beta: {
newUI: 'coming_soon', // Ready for testing but not fully rolled out
experimental: 'hidden',
@@ -61,18 +173,42 @@ export const featureConfig: FeatureFlagConfig = {
// Production environment - stable features only
production: {
// Core platform features (stable)
platform: {
dashboard: 'enabled',
leagues: 'enabled',
teams: 'enabled',
drivers: 'enabled',
races: 'enabled',
leaderboards: 'enabled',
},
// Authentication & onboarding (stable)
auth: {
signup: 'enabled',
login: 'enabled',
forgotPassword: 'enabled',
resetPassword: 'enabled',
},
onboarding: {
wizard: 'enabled',
},
// Sponsor features (gradual rollout)
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'disabled', // Feature not ready yet
campaigns: 'enabled',
billing: 'enabled',
},
// Admin features (stable)
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'disabled', // Feature not ready yet
},
// Beta features (controlled rollout)
beta: {
newUI: 'disabled',
newUI: 'disabled', // Not ready for production
experimental: 'hidden',
},
},

View File

@@ -16,28 +16,87 @@ describe('Feature Flag Integration Test', () => {
expect(result.loadedFrom).toBe('config-file');
expect(result.configPath).toBe('apps/api/src/config/features.config.ts');
// Verify specific features from our config
// Verify core platform features (new structure)
expect(result.features['platform.dashboard']).toBe('enabled');
expect(result.features['platform.leagues']).toBe('enabled');
expect(result.features['platform.teams']).toBe('enabled');
// Verify auth features
expect(result.features['auth.signup']).toBe('enabled');
expect(result.features['auth.login']).toBe('enabled');
// Verify sponsor features (expanded)
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['sponsors.dashboard']).toBe('enabled');
expect(result.features['sponsors.campaigns']).toBe('enabled');
// Verify admin features
expect(result.features['admin.dashboard']).toBe('enabled');
expect(result.features['admin.userManagement']).toBe('enabled');
// Verify beta features (controlled in test)
expect(result.features['beta.newUI']).toBe('disabled');
expect(result.features['beta.experimental']).toBe('disabled');
// Verify utility functions work
expect(isFeatureEnabled(result.features, 'sponsors.portal')).toBe(true);
expect(isFeatureEnabled(result.features, 'platform.dashboard')).toBe(true);
expect(isFeatureEnabled(result.features, 'beta.newUI')).toBe(false);
expect(getFeatureState(result.features, 'sponsors.portal')).toBe('enabled');
expect(getFeatureState(result.features, 'platform.dashboard')).toBe('enabled');
expect(getFeatureState(result.features, 'nonexistent')).toBe('disabled');
});
it('should work with different environments', async () => {
// Test development environment
// Test development environment - alpha mode (all enabled)
process.env.NODE_ENV = 'development';
const devResult = await loadFeatureConfig();
expect(devResult.features['beta.newUI']).toBe('enabled'); // dev has beta enabled
expect(devResult.features['beta.newUI']).toBe('enabled');
expect(devResult.features['platform.dashboard']).toBe('enabled');
expect(devResult.features['sponsors.management']).toBe('enabled');
// Test production environment
// Test production environment - beta mode (controlled rollout)
process.env.NODE_ENV = 'production';
const prodResult = await loadFeatureConfig();
expect(prodResult.features['beta.newUI']).toBe('disabled'); // prod has beta disabled
expect(prodResult.features['beta.newUI']).toBe('disabled');
expect(prodResult.features['platform.dashboard']).toBe('enabled');
expect(prodResult.features['sponsors.management']).toBe('disabled'); // Not ready yet
expect(prodResult.features['admin.analytics']).toBe('disabled'); // Not ready yet
});
it('should handle the new two-tier architecture correctly', async () => {
// Development should have all platform features enabled (alpha mode)
process.env.NODE_ENV = 'development';
const devResult = await loadFeatureConfig();
// All core platform features should be enabled
expect(devResult.features['platform.dashboard']).toBe('enabled');
expect(devResult.features['platform.leagues']).toBe('enabled');
expect(devResult.features['platform.teams']).toBe('enabled');
expect(devResult.features['platform.drivers']).toBe('enabled');
expect(devResult.features['platform.races']).toBe('enabled');
expect(devResult.features['platform.leaderboards']).toBe('enabled');
// All auth features should be enabled
expect(devResult.features['auth.signup']).toBe('enabled');
expect(devResult.features['auth.login']).toBe('enabled');
expect(devResult.features['auth.forgotPassword']).toBe('enabled');
expect(devResult.features['auth.resetPassword']).toBe('enabled');
// Onboarding should be enabled
expect(devResult.features['onboarding.wizard']).toBe('enabled');
});
it('should return flattened features with expected structure', async () => {
process.env.NODE_ENV = 'test';
const result = await loadFeatureConfig();
// Verify the result has the expected shape for API response
expect(result).toHaveProperty('features');
expect(result).toHaveProperty('loadedFrom');
expect(result).toHaveProperty('configPath');
// Verify features is a flat object with dot-notation keys
expect(typeof result.features).toBe('object');
expect(result.features['platform.dashboard']).toBeDefined();
expect(result.features['sponsors.portal']).toBeDefined();
});
});

View File

@@ -40,7 +40,7 @@ export class RaceDetailPresenter implements UseCaseOutputPort<GetRaceDetailResul
track: output.race.track,
car: output.race.car,
scheduledAt: output.race.scheduledAt.toISOString(),
sessionType: output.race.sessionType.toString(),
sessionType: output.race.sessionType.props,
status: output.race.status.toString(),
strengthOfField: output.race.strengthOfField?.toNumber() ?? null,
...(output.race.registeredCount !== undefined && {

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeaturesController } from './features.controller';
import type { FlattenedFeatures, ConfigLoadResult } from '../config/feature-types';
// Mock the feature-loader module
vi.mock('../config/feature-loader', () => ({
loadFeatureConfig: vi.fn(),
}));
import { loadFeatureConfig } from '../config/feature-loader';
describe('FeaturesController', () => {
let controller: FeaturesController;
beforeEach(() => {
controller = new FeaturesController();
vi.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should return features with correct shape', async () => {
const mockFeatures: FlattenedFeatures = {
'platform.dashboard': 'enabled',
'platform.leagues': 'enabled',
'sponsors.portal': 'enabled',
};
const mockResult: ConfigLoadResult = {
features: mockFeatures,
loadedFrom: 'config-file',
configPath: 'apps/api/src/config/features.config.ts',
};
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
const result = await controller.getFeatures();
expect(loadFeatureConfig).toHaveBeenCalledTimes(1);
expect(result).toHaveProperty('features');
expect(result).toHaveProperty('loadedFrom');
expect(result).toHaveProperty('timestamp');
expect(result.features).toEqual(mockFeatures);
expect(result.loadedFrom).toBe('config-file');
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should handle empty features', async () => {
const mockResult: ConfigLoadResult = {
features: {},
loadedFrom: 'defaults',
};
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
const result = await controller.getFeatures();
expect(result.features).toEqual({});
expect(result.loadedFrom).toBe('defaults');
expect(typeof result.timestamp).toBe('string');
});
it('should include timestamp as ISO string', async () => {
const mockResult: ConfigLoadResult = {
features: { 'test.feature': 'enabled' as const },
loadedFrom: 'config-file',
};
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
const result = await controller.getFeatures();
const timestamp = new Date(result.timestamp);
expect(timestamp instanceof Date).toBe(true);
expect(timestamp.toISOString()).toBe(result.timestamp);
});
});

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../domain/auth/Public';
import { loadFeatureConfig } from '../config/feature-loader';
@Controller('features')
export class FeaturesController {
@Public()
@Get()
async getFeatures() {
const result = await loadFeatureConfig();
return {
features: result.features,
loadedFrom: result.loadedFrom,
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,102 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FeaturesModule } from './features.module';
// Mock the feature-loader to control test behavior
vi.mock('../config/feature-loader', () => ({
loadFeatureConfig: vi.fn(),
}));
import { loadFeatureConfig } from '../config/feature-loader';
describe('Features HTTP Endpoint', () => {
let app: any;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [FeaturesModule],
}).compile();
app = module.createNestApplication();
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('GET /features returns 200 with correct shape', async () => {
vi.mocked(loadFeatureConfig).mockResolvedValue({
features: {
'platform.dashboard': 'enabled',
'platform.leagues': 'enabled',
'sponsors.portal': 'enabled',
},
loadedFrom: 'config-file',
configPath: 'apps/api/src/config/features.config.ts',
});
const response = await request(app.getHttpServer()).get('/features').expect(200);
expect(response.body).toHaveProperty('features');
expect(response.body).toHaveProperty('loadedFrom');
expect(response.body).toHaveProperty('timestamp');
expect(response.body.features).toEqual({
'platform.dashboard': 'enabled',
'platform.leagues': 'enabled',
'sponsors.portal': 'enabled',
});
expect(response.body.loadedFrom).toBe('config-file');
expect(response.body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('GET /features returns 200 with empty features', async () => {
vi.mocked(loadFeatureConfig).mockResolvedValue({
features: {},
loadedFrom: 'defaults',
});
const response = await request(app.getHttpServer()).get('/features').expect(200);
expect(response.body.features).toEqual({});
expect(response.body.loadedFrom).toBe('defaults');
expect(typeof response.body.timestamp).toBe('string');
});
it('GET /features works without authentication', async () => {
vi.mocked(loadFeatureConfig).mockResolvedValue({
features: { 'test.feature': 'enabled' as const },
loadedFrom: 'config-file',
});
// Should work without any auth headers
const response = await request(app.getHttpServer()).get('/features').expect(200);
expect(response.body.features).toHaveProperty('test.feature');
});
it('GET /features includes timestamp as ISO string', async () => {
const beforeRequest = new Date();
vi.mocked(loadFeatureConfig).mockResolvedValue({
features: { 'platform.dashboard': 'enabled' as const },
loadedFrom: 'config-file',
});
const response = await request(app.getHttpServer()).get('/features').expect(200);
const afterRequest = new Date();
const timestamp = new Date(response.body.timestamp);
expect(timestamp instanceof Date).toBe(true);
expect(timestamp.toISOString()).toBe(response.body.timestamp);
// Timestamp should be within the request window
expect(timestamp >= beforeRequest).toBe(true);
expect(timestamp <= afterRequest).toBe(true);
});
});

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FeaturesController } from './features.controller';
@Module({
controllers: [FeaturesController],
exports: [],
})
export class FeaturesModule {}