diff --git a/apps/api/src/config/feature-loader.test.ts b/apps/api/src/config/feature-loader.test.ts new file mode 100644 index 000000000..f0431fb81 --- /dev/null +++ b/apps/api/src/config/feature-loader.test.ts @@ -0,0 +1,110 @@ +/** + * Test file for the new feature flag configuration system + * + * Run with: npm test -- feature-loader.test.ts + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { loadFeatureConfig, isFeatureEnabled, getFeatureState, getAllFeatures } from './feature-loader'; +import { FlattenedFeatures, FeatureState } from './feature-types'; + +describe('Feature Flag Configuration', () => { + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + // Reset environment if needed + }); + + afterAll(() => { + // Restore original environment + process.env.NODE_ENV = originalEnv; + }); + + describe('loadFeatureConfig', () => { + it('should load development config when NODE_ENV=development', async () => { + process.env.NODE_ENV = 'development'; + const result = await loadFeatureConfig(); + + expect(result.loadedFrom).toBe('config-file'); + expect(result.features).toBeDefined(); + expect(result.features['sponsors.portal']).toBe('enabled'); + expect(result.features['admin.dashboard']).toBe('enabled'); + }); + + it('should load test config when NODE_ENV=test', async () => { + process.env.NODE_ENV = 'test'; + const result = await loadFeatureConfig(); + + expect(result.loadedFrom).toBe('config-file'); + expect(result.features['sponsors.portal']).toBe('enabled'); + expect(result.features['admin.dashboard']).toBe('enabled'); + }); + + it('should load production config when NODE_ENV=production', async () => { + process.env.NODE_ENV = 'production'; + const result = await loadFeatureConfig(); + + expect(result.loadedFrom).toBe('config-file'); + expect(result.features['sponsors.portal']).toBe('enabled'); + expect(result.features['sponsors.management']).toBe('disabled'); + }); + + + it('should handle invalid environment', async () => { + process.env.NODE_ENV = 'invalid-env'; + + await expect(loadFeatureConfig()).rejects.toThrow('Invalid environment'); + }); + }); + + describe('isFeatureEnabled', () => { + it('should return true for enabled features', () => { + const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' }; + expect(isFeatureEnabled(features, 'sponsors.portal')).toBe(true); + }); + + it('should return false for disabled features', () => { + const features: FlattenedFeatures = { 'sponsors.portal': 'disabled' }; + expect(isFeatureEnabled(features, 'sponsors.portal')).toBe(false); + }); + + it('should return false for non-existent features', () => { + const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' }; + expect(isFeatureEnabled(features, 'nonexistent.feature')).toBe(false); + }); + }); + + describe('getFeatureState', () => { + it('should return correct state for existing features', () => { + const features: FlattenedFeatures = { 'sponsors.portal': 'coming_soon' }; + expect(getFeatureState(features, 'sponsors.portal')).toBe('coming_soon'); + }); + + it('should return disabled for non-existent features', () => { + const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' }; + expect(getFeatureState(features, 'nonexistent')).toBe('disabled'); + }); + }); + + describe('getAllFeatures', () => { + it('should return all features for current environment', () => { + process.env.NODE_ENV = 'development'; + const features = getAllFeatures(); + + expect(features).toBeDefined(); + expect(Object.keys(features).length).toBeGreaterThan(0); + }); + }); + + describe('Flattening behavior', () => { + it('should flatten nested feature configurations', async () => { + process.env.NODE_ENV = 'development'; + const result = await loadFeatureConfig(); + + // Should flatten nested objects + expect(result.features['sponsors.portal']).toBeDefined(); + expect(result.features['sponsors.dashboard']).toBeDefined(); + expect(result.features['admin.dashboard']).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/config/feature-loader.ts b/apps/api/src/config/feature-loader.ts new file mode 100644 index 000000000..44430a058 --- /dev/null +++ b/apps/api/src/config/feature-loader.ts @@ -0,0 +1,129 @@ +import { featureConfig } from './features.config'; +import { + FeatureFlagConfig, + FlattenedFeatures, + ConfigLoadResult, + FeatureState +} from './feature-types'; + +/** + * Default configuration path relative to project root + */ +const DEFAULT_CONFIG_PATH = 'apps/api/src/config/features.config.ts'; + +/** + * Flattens nested feature configuration into a simple key-value map + * + * Example: + * Input: { sponsors: { portal: 'enabled' } } + * Output: { 'sponsors.portal': 'enabled' } + */ +function flattenFeatures( + config: Record, + prefix: string = '' +): FlattenedFeatures { + const flattened: FlattenedFeatures = {}; + + for (const [key, value] of Object.entries(config)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Recursively flatten nested objects + Object.assign(flattened, flattenFeatures(value, fullKey)); + } else if (isFeatureState(value)) { + // Assign feature state + flattened[fullKey] = value; + } + } + + return flattened; +} + +/** + * Type guard for FeatureState + */ +function isFeatureState(value: unknown): value is FeatureState { + return ( + value === 'enabled' || + value === 'disabled' || + value === 'coming_soon' || + value === 'hidden' + ); +} + +/** + * Gets the current environment + */ +function getEnvironment(): string { + return process.env.NODE_ENV || 'development'; +} + +/** + * Validates that the environment exists in the config + */ +function validateEnvironment( + env: string +): env is keyof FeatureFlagConfig { + const validEnvs = ['development', 'test', 'staging', 'production']; + if (!validEnvs.includes(env)) { + throw new Error( + `Invalid environment: "${env}". Valid environments: ${validEnvs.join(', ')}` + ); + } + return true; +} + +/** + * Loads feature configuration from the TypeScript config file + */ +export async function loadFeatureConfig(): Promise { + const env = getEnvironment(); + + if (validateEnvironment(env)) { + const envConfig = featureConfig[env]; + const flattened = flattenFeatures(envConfig); + + return { + features: flattened, + loadedFrom: 'config-file', + configPath: DEFAULT_CONFIG_PATH, + }; + } + + // Fallback to defaults + return { + features: {}, + loadedFrom: 'defaults', + }; +} + +/** + * Get a specific feature state + */ +export function getFeatureState( + features: FlattenedFeatures, + featurePath: string +): FeatureState { + return features[featurePath] || 'disabled'; +} + +/** + * Check if a feature is enabled + */ +export function isFeatureEnabled( + features: FlattenedFeatures, + featurePath: string +): boolean { + return getFeatureState(features, featurePath) === 'enabled'; +} + +/** + * Get all features for debugging + */ +export function getAllFeatures(): FlattenedFeatures { + const env = getEnvironment(); + if (validateEnvironment(env)) { + return flattenFeatures(featureConfig[env]); + } + return {}; +} \ No newline at end of file diff --git a/apps/api/src/config/feature-types.ts b/apps/api/src/config/feature-types.ts new file mode 100644 index 000000000..abe635ca3 --- /dev/null +++ b/apps/api/src/config/feature-types.ts @@ -0,0 +1,53 @@ +/** + * Feature Flag Configuration Types + * + * Provides type-safe configuration for feature flags across different environments + */ + +export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden'; + +/** + * Individual feature configuration + */ +export interface FeatureConfig { + [featureName: string]: FeatureState | FeatureConfig; +} + +/** + * Environment-specific feature configurations + */ +export interface EnvironmentConfig { + [featureGroup: string]: FeatureConfig; +} + +/** + * Complete feature flag configuration + */ +export interface FeatureFlagConfig { + development: EnvironmentConfig; + test: EnvironmentConfig; + staging: EnvironmentConfig; + production: EnvironmentConfig; +} + +/** + * Flattened feature map for runtime lookup + * Example: { 'sponsors.portal': 'enabled', 'admin.dashboard': 'enabled' } + */ +export type FlattenedFeatures = Record; + +/** + * Configuration loader result + */ +export interface ConfigLoadResult { + features: FlattenedFeatures; + loadedFrom: 'config-file' | 'env-var' | 'defaults'; + configPath?: string; +} + +/** + * Legacy JSON format for backward compatibility + */ +export interface LegacyFeatureConfig { + [key: string]: FeatureState | LegacyFeatureConfig; +} \ No newline at end of file diff --git a/apps/api/src/config/features.config.example.ts b/apps/api/src/config/features.config.example.ts new file mode 100644 index 000000000..897196db8 --- /dev/null +++ b/apps/api/src/config/features.config.example.ts @@ -0,0 +1,82 @@ +import { FeatureFlagConfig } from './feature-types'; + +/** + * Example feature flag configuration + * + * Copy this file to features.config.ts and customize for your needs + * + * Feature states: + * - 'enabled': Feature is fully available + * - 'disabled': Feature is turned off + * - 'coming_soon': Feature is in development, shown as "coming soon" + * - 'hidden': Feature is hidden from users + */ +export const featureConfig: FeatureFlagConfig = { + development: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'enabled', + experimental: 'coming_soon', + }, + }, + + test: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'disabled', + experimental: 'disabled', + }, + }, + + staging: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'coming_soon', + experimental: 'hidden', + }, + }, + + production: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'disabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'disabled', + }, + beta: { + newUI: 'disabled', + experimental: 'hidden', + }, + }, +}; \ No newline at end of file diff --git a/apps/api/src/config/features.config.ts b/apps/api/src/config/features.config.ts new file mode 100644 index 000000000..b723e22c2 --- /dev/null +++ b/apps/api/src/config/features.config.ts @@ -0,0 +1,79 @@ +import { FeatureFlagConfig } from './feature-types'; + +/** + * Feature flag configuration for all environments + * This provides type safety, IntelliSense, and environment-specific settings + */ +export const featureConfig: FeatureFlagConfig = { + // Development environment - features for local development + development: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'enabled', // Enable new UI for testing + experimental: 'coming_soon', + }, + }, + + // Test environment - features for automated tests + test: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'disabled', + experimental: 'disabled', + }, + }, + + // Staging environment - features for pre-production testing + staging: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'enabled', + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'enabled', + }, + beta: { + newUI: 'coming_soon', // Ready for testing but not fully rolled out + experimental: 'hidden', + }, + }, + + // Production environment - stable features only + production: { + sponsors: { + portal: 'enabled', + dashboard: 'enabled', + management: 'disabled', // Feature not ready yet + }, + admin: { + dashboard: 'enabled', + userManagement: 'enabled', + analytics: 'disabled', // Feature not ready yet + }, + beta: { + newUI: 'disabled', + experimental: 'hidden', + }, + }, +}; \ No newline at end of file diff --git a/apps/api/src/config/integration.test.ts b/apps/api/src/config/integration.test.ts new file mode 100644 index 000000000..a3482f884 --- /dev/null +++ b/apps/api/src/config/integration.test.ts @@ -0,0 +1,43 @@ +/** + * Integration test to verify the feature flag system works end-to-end + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { loadFeatureConfig, isFeatureEnabled, getFeatureState } from './feature-loader'; + +describe('Feature Flag Integration Test', () => { + it('should load config and provide correct feature states', async () => { + // Set test environment + process.env.NODE_ENV = 'test'; + + const result = await loadFeatureConfig(); + + // Verify config loaded from file + expect(result.loadedFrom).toBe('config-file'); + expect(result.configPath).toBe('apps/api/src/config/features.config.ts'); + + // Verify specific features from our config + expect(result.features['sponsors.portal']).toBe('enabled'); + expect(result.features['sponsors.dashboard']).toBe('enabled'); + expect(result.features['admin.dashboard']).toBe('enabled'); + expect(result.features['beta.newUI']).toBe('disabled'); + + // Verify utility functions work + expect(isFeatureEnabled(result.features, 'sponsors.portal')).toBe(true); + expect(isFeatureEnabled(result.features, 'beta.newUI')).toBe(false); + expect(getFeatureState(result.features, 'sponsors.portal')).toBe('enabled'); + expect(getFeatureState(result.features, 'nonexistent')).toBe('disabled'); + }); + + it('should work with different environments', async () => { + // Test development environment + process.env.NODE_ENV = 'development'; + const devResult = await loadFeatureConfig(); + expect(devResult.features['beta.newUI']).toBe('enabled'); // dev has beta enabled + + // Test production environment + process.env.NODE_ENV = 'production'; + const prodResult = await loadFeatureConfig(); + expect(prodResult.features['beta.newUI']).toBe('disabled'); // prod has beta disabled + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyService.ts b/apps/api/src/domain/policy/PolicyService.ts index c1d5fcfa3..abccf289f 100644 --- a/apps/api/src/domain/policy/PolicyService.ts +++ b/apps/api/src/domain/policy/PolicyService.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; +import { loadFeatureConfig } from '../../config/feature-loader'; +import { FlattenedFeatures } from '../../config/feature-types'; export type OperationalMode = 'normal' | 'maintenance' | 'test'; export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden'; @@ -72,24 +74,18 @@ export class PolicyService { }; } - const anyEnvConfigured = - Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) || - Boolean(process.env.GRIDPILOT_FEATURES_JSON) || - Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) || - Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); - - const raw: RawPolicySnapshot = {}; + // Load from TypeScript config file + const configResult = await loadFeatureConfig(); + const raw: RawPolicySnapshot = { + capabilities: convertFlattenedToCapabilities(configResult.features), + }; + // Include other env vars if set const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE; if (operationalMode) { raw.operationalMode = operationalMode; } - const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON); - if (capabilities) { - raw.capabilities = capabilities; - } - const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW); const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); @@ -102,7 +98,7 @@ export class PolicyService { return { raw, - loadedFrom: anyEnvConfigured ? 'env' : 'defaults', + loadedFrom: configResult.loadedFrom === 'config-file' ? 'file' : 'defaults', }; } } @@ -191,16 +187,6 @@ function parseFeatureState(value: unknown): FeatureState | null { } } -function parseFeaturesJson(raw: string | undefined): unknown { - if (!raw) { - return undefined; - } - try { - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } -} function parseCsvList(raw: string | undefined): readonly string[] | undefined { if (!raw) { @@ -226,4 +212,11 @@ function normalizeStringArray(value: unknown): readonly string[] { .filter(Boolean); return normalized; +} + +// NEW: Helper function to convert flattened features to capabilities +function convertFlattenedToCapabilities( + flattened: FlattenedFeatures +): Record { + return flattened as Record; } \ No newline at end of file diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts index 5fc453ec0..eeaf4376f 100644 --- a/apps/api/src/env.d.ts +++ b/apps/api/src/env.d.ts @@ -20,7 +20,6 @@ declare global { GRIDPILOT_POLICY_CACHE_MS?: string; GRIDPILOT_POLICY_PATH?: string; GRIDPILOT_OPERATIONAL_MODE?: string; - GRIDPILOT_FEATURES_JSON?: string; GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string; GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string; diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 825014946..bddcbbacc 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -30,7 +30,6 @@ services: - GRIDPILOT_API_PERSISTENCE=postgres - GRIDPILOT_API_BOOTSTRAP=true - GRIDPILOT_API_FORCE_RESEED=true - - GRIDPILOT_FEATURES_JSON={"sponsors.portal":"enabled","admin.dashboard":"enabled"} - DATABASE_URL=postgres://gridpilot_e2e_user:gridpilot_e2e_pass@db:5432/gridpilot_e2e - POSTGRES_DB=gridpilot_e2e - POSTGRES_USER=gridpilot_e2e_user diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 4a3319965..3f523de01 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -46,7 +46,6 @@ services: - GRIDPILOT_API_PERSISTENCE=postgres - GRIDPILOT_API_BOOTSTRAP=true - GRIDPILOT_API_FORCE_RESEED=true - - GRIDPILOT_FEATURES_JSON={"sponsors.portal":"enabled","admin.dashboard":"enabled"} - DATABASE_URL=postgres://gridpilot_test_user:gridpilot_test_pass@db:5432/gridpilot_test - POSTGRES_DB=gridpilot_test - POSTGRES_USER=gridpilot_test_user diff --git a/tests/e2e/website/website-pages.e2e.test.ts b/tests/e2e/website/website-pages.e2e.test.ts index c7df51436..b073bd628 100644 --- a/tests/e2e/website/website-pages.e2e.test.ts +++ b/tests/e2e/website/website-pages.e2e.test.ts @@ -1,7 +1,7 @@ -import { test, expect } from '@playwright/test'; -import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager'; -import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager'; +import { expect, test } from '@playwright/test'; import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture'; +import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager'; +import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager'; const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000'; @@ -538,4 +538,5 @@ test.describe('Website Pages - TypeORM Integration', () => { expect(detailBodyText).toBeTruthy(); expect(detailBodyText?.length).toBeGreaterThan(50); }); -}); \ No newline at end of file + +});