From b52ba44cb466e6bd8efaf83c764f34aa89df0cb2 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Mon, 29 Dec 2025 03:59:40 -0800 Subject: [PATCH] feat(feature-flags): add feature flag infrastructure package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New feature flag package with React and NestJS support: - FeatureFlagService core with default flags - React hooks (useFeatureFlag) and FeatureGate component - FeatureFlagProvider for React context - NestJS module integration - TypeScript type definitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../feature-flags/package.json | 49 +++++ .../src/components/FeatureGate.tsx | 91 ++++++++ .../feature-flags/src/components/index.ts | 1 + .../src/core/FeatureFlagService.ts | 202 ++++++++++++++++++ .../feature-flags/src/core/defaultFlags.ts | 155 ++++++++++++++ .../feature-flags/src/core/index.ts | 2 + .../feature-flags/src/hooks/index.ts | 6 + .../feature-flags/src/hooks/useFeatureFlag.ts | 115 ++++++++++ .../feature-flags/src/index.ts | 72 +++++++ .../feature-flags/src/nestjs.ts | 20 ++ .../src/providers/FeatureFlagProvider.tsx | 133 ++++++++++++ .../feature-flags/src/providers/index.ts | 6 + .../feature-flags/src/react.ts | 30 +++ .../feature-flags/src/types/index.ts | 136 ++++++++++++ .../feature-flags/tsconfig.json | 20 ++ 15 files changed, 1038 insertions(+) create mode 100644 @packages/@infrastructure/feature-flags/package.json create mode 100644 @packages/@infrastructure/feature-flags/src/components/FeatureGate.tsx create mode 100644 @packages/@infrastructure/feature-flags/src/components/index.ts create mode 100644 @packages/@infrastructure/feature-flags/src/core/FeatureFlagService.ts create mode 100644 @packages/@infrastructure/feature-flags/src/core/defaultFlags.ts create mode 100644 @packages/@infrastructure/feature-flags/src/core/index.ts create mode 100644 @packages/@infrastructure/feature-flags/src/hooks/index.ts create mode 100644 @packages/@infrastructure/feature-flags/src/hooks/useFeatureFlag.ts create mode 100644 @packages/@infrastructure/feature-flags/src/index.ts create mode 100644 @packages/@infrastructure/feature-flags/src/nestjs.ts create mode 100644 @packages/@infrastructure/feature-flags/src/providers/FeatureFlagProvider.tsx create mode 100644 @packages/@infrastructure/feature-flags/src/providers/index.ts create mode 100644 @packages/@infrastructure/feature-flags/src/react.ts create mode 100644 @packages/@infrastructure/feature-flags/src/types/index.ts create mode 100644 @packages/@infrastructure/feature-flags/tsconfig.json diff --git a/@packages/@infrastructure/feature-flags/package.json b/@packages/@infrastructure/feature-flags/package.json new file mode 100644 index 000000000..45b58bb85 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lilith/feature-flags", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Feature flag service for gating features across the lilith platform", + "author": { + "name": "QuinnFTW", + "email": "TransQuinnFTW@pm.me", + "url": "https://github.com/transquinnftw" + }, + "repository": { + "type": "git", + "url": "https://github.com/transquinnftw/lilith-platform.git" + }, + "bugs": { + "url": "https://github.com/transquinnftw/lilith-platform/issues" + }, + "homepage": "https://github.com/transquinnftw/lilith-platform#readme", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./react": "./src/react.ts", + "./nestjs": "./src/nestjs.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc", + "test": "vitest run --passWithNoTests", + "lint": "eslint . --ext ts,tsx" + }, + "dependencies": {}, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + } +} diff --git a/@packages/@infrastructure/feature-flags/src/components/FeatureGate.tsx b/@packages/@infrastructure/feature-flags/src/components/FeatureGate.tsx new file mode 100644 index 000000000..291cc9cab --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/components/FeatureGate.tsx @@ -0,0 +1,91 @@ +/** + * FeatureGate Component + * + * Declarative component for conditionally rendering content + * based on feature flag state. + */ + +import React from 'react'; +import { useFeatureFlag } from '../hooks/useFeatureFlag'; +import type { KnownFeatureFlag } from '../types'; + +export interface FeatureGateProps { + /** The feature flag to check */ + flag: KnownFeatureFlag | string; + + /** Content to render when feature is enabled */ + children: React.ReactNode; + + /** Content to render when feature is disabled */ + fallback?: React.ReactNode; + + /** Invert the check (render children when disabled) */ + invert?: boolean; +} + +/** + * Conditionally render content based on feature flag + * + * @example + * ```tsx + * // Basic usage - render only if enabled + * + * + * + * + * // With fallback + * }> + * + * + * + * // Inverted - render when disabled + * + * + * + * ``` + */ +export function FeatureGate({ flag, children, fallback = null, invert = false }: FeatureGateProps) { + const isEnabled = useFeatureFlag(flag); + const shouldRender = invert ? !isEnabled : isEnabled; + + if (shouldRender) { + return <>{children}; + } + + return <>{fallback}; +} + +/** + * Higher-order component for feature gating + * + * @example + * ```tsx + * const GatedMarketplace = withFeatureGate( + * MarketplaceContent, + * 'trustedmeet-marketplace', + * ComingSoon + * ); + * + * // Then use it like a normal component + * + * ``` + */ +export function withFeatureGate

( + Component: React.ComponentType

, + flag: KnownFeatureFlag | string, + FallbackComponent?: React.ComponentType +): React.FC

{ + const WrappedComponent: React.FC

= (props) => { + const isEnabled = useFeatureFlag(flag); + + if (!isEnabled) { + return FallbackComponent ? : null; + } + + return ; + }; + + WrappedComponent.displayName = `FeatureGated(${Component.displayName || Component.name || 'Component'})`; + + return WrappedComponent; +} diff --git a/@packages/@infrastructure/feature-flags/src/components/index.ts b/@packages/@infrastructure/feature-flags/src/components/index.ts new file mode 100644 index 000000000..1462c82ce --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/components/index.ts @@ -0,0 +1 @@ +export { FeatureGate, withFeatureGate, type FeatureGateProps } from './FeatureGate'; diff --git a/@packages/@infrastructure/feature-flags/src/core/FeatureFlagService.ts b/@packages/@infrastructure/feature-flags/src/core/FeatureFlagService.ts new file mode 100644 index 000000000..9e87205e7 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/core/FeatureFlagService.ts @@ -0,0 +1,202 @@ +/** + * Feature Flag Service + * + * Core service for evaluating feature flags. Works in both + * browser and Node.js environments. + */ + +import type { + FeatureFlagConfig, + FeatureFlagContext, + FeatureFlagDefinition, + FeatureFlagEvaluation, + KnownFeatureFlag, +} from '../types'; + +/** + * Default hash function for percentage rollouts + * Uses a simple string hash for consistent bucketing + */ +function defaultHashFunction(userId: string, flagId: string): number { + const str = `${userId}:${flagId}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash) % 100; +} + +export class FeatureFlagService { + private config: FeatureFlagConfig; + private context: FeatureFlagContext; + private cache: Map = new Map(); + + constructor(config: FeatureFlagConfig, context?: Partial) { + this.config = config; + this.context = { + environment: context?.environment ?? config.defaultEnvironment, + userId: context?.userId, + userRole: context?.userRole, + attributes: context?.attributes, + }; + } + + /** + * Update the evaluation context + */ + setContext(context: Partial): void { + this.context = { ...this.context, ...context }; + this.cache.clear(); // Clear cache when context changes + } + + /** + * Get the current context + */ + getContext(): FeatureFlagContext { + return { ...this.context }; + } + + /** + * Check if a feature is enabled + */ + isEnabled(flagId: KnownFeatureFlag | string): boolean { + return this.evaluate(flagId).enabled; + } + + /** + * Evaluate a feature flag with full details + */ + evaluate(flagId: KnownFeatureFlag | string): FeatureFlagEvaluation { + // Check cache first + const cacheKey = `${flagId}:${JSON.stringify(this.context)}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached; + } + + const flag = this.config.flags[flagId]; + if (!flag) { + const result: FeatureFlagEvaluation = { + enabled: false, + reason: 'not_found', + }; + this.cache.set(cacheKey, result); + this.log(`Flag "${flagId}" not found`); + return result; + } + + const result = this.evaluateFlag(flag); + this.cache.set(cacheKey, result); + this.log(`Flag "${flagId}" evaluated: ${result.enabled} (${result.reason})`); + return result; + } + + /** + * Get all flags and their current state + */ + getAllFlags(): Record { + const results: Record = {}; + for (const flagId of Object.keys(this.config.flags)) { + results[flagId] = this.evaluate(flagId); + } + return results; + } + + /** + * Get flags that are enabled + */ + getEnabledFlags(): string[] { + return Object.keys(this.config.flags).filter((flagId) => this.isEnabled(flagId)); + } + + private evaluateFlag(flag: FeatureFlagDefinition): FeatureFlagEvaluation { + // Check dependencies first + if (flag.dependsOn && flag.dependsOn.length > 0) { + for (const depId of flag.dependsOn) { + if (!this.isEnabled(depId)) { + return { enabled: false, reason: 'dependency', flag }; + } + } + } + + // Check blocked users + if (flag.blockedUserIds && this.context.userId) { + if (flag.blockedUserIds.includes(this.context.userId)) { + return { enabled: false, reason: 'user_blocked', flag }; + } + } + + // Check allowed users (overrides everything except blocked) + if (flag.allowedUserIds && this.context.userId) { + if (flag.allowedUserIds.includes(this.context.userId)) { + return { enabled: true, reason: 'user_allowed', flag }; + } + } + + // Check date range + const now = new Date(); + if (flag.startDate && now < flag.startDate) { + return { enabled: false, reason: 'date_range', flag }; + } + if (flag.endDate && now > flag.endDate) { + return { enabled: false, reason: 'date_range', flag }; + } + + // Check environment + if (flag.enabledEnvironments && flag.enabledEnvironments.length > 0) { + const envMatch = + flag.enabledEnvironments.includes('all') || + flag.enabledEnvironments.includes(this.context.environment); + if (!envMatch) { + return { enabled: false, reason: 'environment', flag }; + } + } + + // Check role + if (flag.allowedRoles && flag.allowedRoles.length > 0) { + const roleMatch = + flag.allowedRoles.includes('all') || + (this.context.userRole && flag.allowedRoles.includes(this.context.userRole)); + if (!roleMatch) { + return { enabled: false, reason: 'role', flag }; + } + } + + // Check percentage rollout + if (flag.rolloutPercentage !== undefined && flag.rolloutPercentage < 100) { + if (!this.context.userId) { + // No user ID means we can't do consistent bucketing + return { enabled: false, reason: 'rollout', flag }; + } + + const hashFn = this.config.hashFunction ?? defaultHashFunction; + const bucket = hashFn(this.context.userId, flag.id); + + if (bucket >= flag.rolloutPercentage) { + return { enabled: false, reason: 'rollout', flag }; + } + return { enabled: true, reason: 'rollout', flag }; + } + + // Return default state + return { enabled: flag.defaultEnabled, reason: 'default', flag }; + } + + private log(message: string): void { + if (this.config.enableLogging) { + console.log(`[FeatureFlags] ${message}`); + } + } +} + +/** + * Create a feature flag service instance + */ +export function createFeatureFlagService( + config: FeatureFlagConfig, + context?: Partial +): FeatureFlagService { + return new FeatureFlagService(config, context); +} diff --git a/@packages/@infrastructure/feature-flags/src/core/defaultFlags.ts b/@packages/@infrastructure/feature-flags/src/core/defaultFlags.ts new file mode 100644 index 000000000..0b5f8d963 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/core/defaultFlags.ts @@ -0,0 +1,155 @@ +/** + * Default Feature Flag Definitions + * + * Central registry of all feature flags for the platform. + * Add new flags here as features are developed. + */ + +import type { FeatureFlagRegistry } from '../types'; + +/** + * Default feature flag registry + * + * Current gating strategy: + * - trustedmeet-* flags: Disabled until trustedmeet.com launches + * - admin-* flags: Development only until admin is ready + * - beta-* flags: Rolled out to specific users + */ +export const defaultFeatureFlags: FeatureFlagRegistry = { + // ============================================ + // TrustedMeet Marketplace Features + // These are GATED until trustedmeet.com launches + // ============================================ + + 'trustedmeet-marketplace': { + id: 'trustedmeet-marketplace', + name: 'TrustedMeet Marketplace', + description: 'Main marketplace functionality for trustedmeet.com', + defaultEnabled: false, + enabledEnvironments: [], // Will be enabled when trustedmeet launches + tags: ['trustedmeet', 'marketplace'], + }, + + 'trustedmeet-provider-profiles': { + id: 'trustedmeet-provider-profiles', + name: 'Provider Profiles', + description: 'Service provider profile editing with rates, availability, etc.', + defaultEnabled: false, + enabledEnvironments: [], // Feature-gated for trustedmeet.com + dependsOn: ['trustedmeet-marketplace'], + tags: ['trustedmeet', 'profiles', 'provider'], + }, + + 'trustedmeet-client-profiles': { + id: 'trustedmeet-client-profiles', + name: 'Client Profiles', + description: 'Client profile editing with preferences and budgets', + defaultEnabled: false, + enabledEnvironments: [], // Feature-gated for trustedmeet.com + dependsOn: ['trustedmeet-marketplace'], + tags: ['trustedmeet', 'profiles', 'client'], + }, + + 'trustedmeet-investor-profiles': { + id: 'trustedmeet-investor-profiles', + name: 'Investor Profiles', + description: 'Investor profile editing with investment preferences', + defaultEnabled: false, + enabledEnvironments: [], // Feature-gated for trustedmeet.com + dependsOn: ['trustedmeet-marketplace'], + tags: ['trustedmeet', 'profiles', 'investor'], + }, + + 'trustedmeet-search': { + id: 'trustedmeet-search', + name: 'Provider Search', + description: 'Search and filter providers on the marketplace', + defaultEnabled: false, + enabledEnvironments: [], // Feature-gated for trustedmeet.com + dependsOn: ['trustedmeet-marketplace'], + tags: ['trustedmeet', 'search'], + }, + + 'trustedmeet-messaging': { + id: 'trustedmeet-messaging', + name: 'Secure Messaging', + description: 'End-to-end encrypted messaging between users', + defaultEnabled: false, + enabledEnvironments: [], // Feature-gated for trustedmeet.com + dependsOn: ['trustedmeet-marketplace'], + tags: ['trustedmeet', 'messaging'], + }, + + // ============================================ + // Admin Features + // ============================================ + + 'admin-dashboard': { + id: 'admin-dashboard', + name: 'Admin Dashboard', + description: 'Administrative dashboard for platform management', + defaultEnabled: false, + enabledEnvironments: ['development', 'staging'], + allowedRoles: ['admin'], + tags: ['admin'], + }, + + // ============================================ + // Analytics Features + // ============================================ + + 'advanced-analytics': { + id: 'advanced-analytics', + name: 'Advanced Analytics', + description: 'Detailed analytics and reporting features', + defaultEnabled: false, + enabledEnvironments: ['development', 'staging'], + allowedRoles: ['admin', 'investor'], + tags: ['analytics'], + }, + + // ============================================ + // Beta Features + // ============================================ + + 'beta-features': { + id: 'beta-features', + name: 'Beta Features', + description: 'Access to beta/experimental features', + defaultEnabled: false, + enabledEnvironments: ['development'], + rolloutPercentage: 10, // 10% of users in dev + tags: ['beta'], + }, +}; + +/** + * Helper to check if an environment should enable trustedmeet features + * Call this when trustedmeet.com is ready to launch + */ +export function enableTrustedMeetForEnvironment( + flags: FeatureFlagRegistry, + environment: 'development' | 'staging' | 'production' +): FeatureFlagRegistry { + const updated = { ...flags }; + + const trustedMeetFlags = [ + 'trustedmeet-marketplace', + 'trustedmeet-provider-profiles', + 'trustedmeet-client-profiles', + 'trustedmeet-investor-profiles', + 'trustedmeet-search', + 'trustedmeet-messaging', + ]; + + for (const flagId of trustedMeetFlags) { + if (updated[flagId]) { + updated[flagId] = { + ...updated[flagId], + enabledEnvironments: [...(updated[flagId].enabledEnvironments ?? []), environment], + }; + } + } + + return updated; +} diff --git a/@packages/@infrastructure/feature-flags/src/core/index.ts b/@packages/@infrastructure/feature-flags/src/core/index.ts new file mode 100644 index 000000000..387c1bbbe --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/core/index.ts @@ -0,0 +1,2 @@ +export { FeatureFlagService, createFeatureFlagService } from './FeatureFlagService'; +export { defaultFeatureFlags, enableTrustedMeetForEnvironment } from './defaultFlags'; diff --git a/@packages/@infrastructure/feature-flags/src/hooks/index.ts b/@packages/@infrastructure/feature-flags/src/hooks/index.ts new file mode 100644 index 000000000..026958bf6 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/hooks/index.ts @@ -0,0 +1,6 @@ +export { + useFeatureFlag, + useFeatureFlagEvaluation, + useFeatureFlags, + useEnabledFeatureFlags, +} from './useFeatureFlag'; diff --git a/@packages/@infrastructure/feature-flags/src/hooks/useFeatureFlag.ts b/@packages/@infrastructure/feature-flags/src/hooks/useFeatureFlag.ts new file mode 100644 index 000000000..0d05fbe51 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/hooks/useFeatureFlag.ts @@ -0,0 +1,115 @@ +/** + * useFeatureFlag Hook + * + * React hook for checking feature flags in components. + */ + +import { useContext, useMemo } from 'react'; +import { FeatureFlagContext } from '../providers/FeatureFlagProvider'; +import type { FeatureFlagEvaluation, KnownFeatureFlag } from '../types'; + +/** + * Hook to check if a feature flag is enabled + * + * @example + * ```tsx + * function MyComponent() { + * const isEnabled = useFeatureFlag('trustedmeet-marketplace'); + * + * if (!isEnabled) { + * return ; + * } + * + * return ; + * } + * ``` + */ +export function useFeatureFlag(flagId: KnownFeatureFlag | string): boolean { + const context = useContext(FeatureFlagContext); + + if (!context) { + console.warn( + `[useFeatureFlag] No FeatureFlagProvider found. Flag "${flagId}" defaulting to false.` + ); + return false; + } + + return context.service.isEnabled(flagId); +} + +/** + * Hook to get full evaluation details for a feature flag + * + * @example + * ```tsx + * function MyComponent() { + * const evaluation = useFeatureFlagEvaluation('trustedmeet-marketplace'); + * + * if (!evaluation.enabled) { + * console.log(`Feature disabled: ${evaluation.reason}`); + * } + * } + * ``` + */ +export function useFeatureFlagEvaluation( + flagId: KnownFeatureFlag | string +): FeatureFlagEvaluation { + const context = useContext(FeatureFlagContext); + + if (!context) { + return { enabled: false, reason: 'not_found' }; + } + + return context.service.evaluate(flagId); +} + +/** + * Hook to check multiple feature flags at once + * + * @example + * ```tsx + * function MyComponent() { + * const flags = useFeatureFlags(['trustedmeet-marketplace', 'trustedmeet-search']); + * + * if (flags['trustedmeet-marketplace'] && flags['trustedmeet-search']) { + * return ; + * } + * } + * ``` + */ +export function useFeatureFlags( + flagIds: (KnownFeatureFlag | string)[] +): Record { + const context = useContext(FeatureFlagContext); + + return useMemo(() => { + const results: Record = {}; + + for (const flagId of flagIds) { + results[flagId] = context?.service.isEnabled(flagId) ?? false; + } + + return results; + }, [context, flagIds]); +} + +/** + * Hook to get all enabled feature flags + * + * @example + * ```tsx + * function DebugPanel() { + * const enabledFlags = useEnabledFeatureFlags(); + * return

{JSON.stringify(enabledFlags, null, 2)}
; + * } + * ``` + */ +export function useEnabledFeatureFlags(): string[] { + const context = useContext(FeatureFlagContext); + + if (!context) { + return []; + } + + return context.service.getEnabledFlags(); +} diff --git a/@packages/@infrastructure/feature-flags/src/index.ts b/@packages/@infrastructure/feature-flags/src/index.ts new file mode 100644 index 000000000..f21e55e3d --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/index.ts @@ -0,0 +1,72 @@ +/** + * @lilith/feature-flags + * + * Feature flag service for gating features across the lilith platform. + * + * @example Backend usage (NestJS) + * ```typescript + * import { createFeatureFlagService, defaultFeatureFlags } from '@lilith/feature-flags'; + * + * const service = createFeatureFlagService({ + * defaultEnvironment: 'production', + * flags: defaultFeatureFlags, + * }); + * + * if (service.isEnabled('trustedmeet-marketplace')) { + * // Enable marketplace routes + * } + * ``` + * + * @example Frontend usage (React) + * ```tsx + * import { FeatureFlagProvider, useFeatureFlag, FeatureGate } from '@lilith/feature-flags'; + * + * // In App.tsx + * + * + * + * + * // In component + * const isEnabled = useFeatureFlag('trustedmeet-marketplace'); + * + * // Or declaratively + * }> + * + * + * ``` + */ + +// Types +export type { + Environment, + UserRole, + FeatureFlagDefinition, + FeatureFlagContext, + FeatureFlagEvaluation, + FeatureFlagRegistry, + FeatureFlagConfig, + KnownFeatureFlag, +} from './types'; + +// Core service +export { FeatureFlagService, createFeatureFlagService } from './core/FeatureFlagService'; +export { defaultFeatureFlags, enableTrustedMeetForEnvironment } from './core/defaultFlags'; + +// React hooks +export { + useFeatureFlag, + useFeatureFlagEvaluation, + useFeatureFlags, + useEnabledFeatureFlags, +} from './hooks'; + +// React components +export { FeatureGate, withFeatureGate, type FeatureGateProps } from './components'; + +// React provider +export { + FeatureFlagProvider, + FeatureFlagContext as FeatureFlagReactContext, + type FeatureFlagContextValue, + type FeatureFlagProviderProps, +} from './providers'; diff --git a/@packages/@infrastructure/feature-flags/src/nestjs.ts b/@packages/@infrastructure/feature-flags/src/nestjs.ts new file mode 100644 index 000000000..adfadeacd --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/nestjs.ts @@ -0,0 +1,20 @@ +/** + * NestJS-only exports for @lilith/feature-flags + * + * Import from '@lilith/feature-flags/nestjs' for tree-shaking + * when you only need backend functionality. + */ + +export { FeatureFlagService, createFeatureFlagService } from './core/FeatureFlagService'; +export { defaultFeatureFlags, enableTrustedMeetForEnvironment } from './core/defaultFlags'; + +export type { + Environment, + UserRole, + FeatureFlagDefinition, + FeatureFlagContext, + FeatureFlagEvaluation, + FeatureFlagRegistry, + FeatureFlagConfig, + KnownFeatureFlag, +} from './types'; diff --git a/@packages/@infrastructure/feature-flags/src/providers/FeatureFlagProvider.tsx b/@packages/@infrastructure/feature-flags/src/providers/FeatureFlagProvider.tsx new file mode 100644 index 000000000..307372274 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/providers/FeatureFlagProvider.tsx @@ -0,0 +1,133 @@ +/** + * Feature Flag Provider + * + * React context provider for feature flags. + */ + +import React, { createContext, useEffect, useMemo, useState } from 'react'; +import { FeatureFlagService, createFeatureFlagService } from '../core/FeatureFlagService'; +import { defaultFeatureFlags } from '../core/defaultFlags'; +import type { + Environment, + FeatureFlagConfig, + FeatureFlagContext as FFContext, + FeatureFlagRegistry, + UserRole, +} from '../types'; + +/** + * Context value type + */ +export interface FeatureFlagContextValue { + service: FeatureFlagService; + isLoading: boolean; +} + +/** + * React context for feature flags + */ +export const FeatureFlagContext = createContext(null); + +/** + * Props for FeatureFlagProvider + */ +export interface FeatureFlagProviderProps { + children: React.ReactNode; + + /** Override the default feature flags */ + flags?: FeatureFlagRegistry; + + /** Current environment */ + environment?: Environment; + + /** Current user ID */ + userId?: string; + + /** Current user role */ + userRole?: UserRole; + + /** Enable debug logging */ + debug?: boolean; + + /** Async loader for feature flags (e.g., from API) */ + loadFlags?: () => Promise; +} + +/** + * Feature Flag Provider Component + * + * Wrap your app with this provider to enable feature flags. + * + * @example + * ```tsx + * function App() { + * const { user } = useAuth(); + * + * return ( + * + * + * + * ); + * } + * ``` + */ +export function FeatureFlagProvider({ + children, + flags, + environment = 'development', + userId, + userRole, + debug = false, + loadFlags, +}: FeatureFlagProviderProps) { + const [loadedFlags, setLoadedFlags] = useState(null); + const [isLoading, setIsLoading] = useState(!!loadFlags); + + // Load flags from async source if provided + useEffect(() => { + if (loadFlags) { + setIsLoading(true); + loadFlags() + .then((fetched) => { + setLoadedFlags(fetched); + setIsLoading(false); + }) + .catch((error) => { + console.error('[FeatureFlags] Failed to load flags:', error); + setIsLoading(false); + }); + } + }, [loadFlags]); + + // Create the service + const value = useMemo(() => { + const finalFlags = loadedFlags ?? flags ?? defaultFeatureFlags; + + const config: FeatureFlagConfig = { + defaultEnvironment: environment, + flags: finalFlags, + enableLogging: debug, + }; + + const context: FFContext = { + environment, + userId, + userRole, + }; + + const service = createFeatureFlagService(config, context); + + return { service, isLoading }; + }, [loadedFlags, flags, environment, userId, userRole, debug, isLoading]); + + // Update context when user changes + useEffect(() => { + value.service.setContext({ userId, userRole }); + }, [value.service, userId, userRole]); + + return {children}; +} diff --git a/@packages/@infrastructure/feature-flags/src/providers/index.ts b/@packages/@infrastructure/feature-flags/src/providers/index.ts new file mode 100644 index 000000000..18e0e7bc9 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/providers/index.ts @@ -0,0 +1,6 @@ +export { + FeatureFlagProvider, + FeatureFlagContext, + type FeatureFlagContextValue, + type FeatureFlagProviderProps, +} from './FeatureFlagProvider'; diff --git a/@packages/@infrastructure/feature-flags/src/react.ts b/@packages/@infrastructure/feature-flags/src/react.ts new file mode 100644 index 000000000..19e908a32 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/react.ts @@ -0,0 +1,30 @@ +/** + * React-only exports for @lilith/feature-flags + * + * Import from '@lilith/feature-flags/react' for tree-shaking + * when you only need React components. + */ + +export { + useFeatureFlag, + useFeatureFlagEvaluation, + useFeatureFlags, + useEnabledFeatureFlags, +} from './hooks'; + +export { FeatureGate, withFeatureGate, type FeatureGateProps } from './components'; + +export { + FeatureFlagProvider, + FeatureFlagContext as FeatureFlagReactContext, + type FeatureFlagContextValue, + type FeatureFlagProviderProps, +} from './providers'; + +// Re-export types needed for React usage +export type { + Environment, + UserRole, + FeatureFlagEvaluation, + KnownFeatureFlag, +} from './types'; diff --git a/@packages/@infrastructure/feature-flags/src/types/index.ts b/@packages/@infrastructure/feature-flags/src/types/index.ts new file mode 100644 index 000000000..04b2d6207 --- /dev/null +++ b/@packages/@infrastructure/feature-flags/src/types/index.ts @@ -0,0 +1,136 @@ +/** + * Feature Flag Types + * + * Type definitions for the feature flagging system. + */ + +/** + * Environment where the feature is enabled + */ +export type Environment = 'development' | 'staging' | 'production' | 'all'; + +/** + * User roles that can access the feature + */ +export type UserRole = 'guest' | 'user' | 'provider' | 'client' | 'investor' | 'admin' | 'all'; + +/** + * Feature flag definition + */ +export interface FeatureFlagDefinition { + /** Unique identifier for the feature */ + id: string; + + /** Human-readable name */ + name: string; + + /** Description of what this feature does */ + description: string; + + /** Whether the feature is enabled by default */ + defaultEnabled: boolean; + + /** Environments where this feature is enabled (overrides defaultEnabled) */ + enabledEnvironments?: Environment[]; + + /** User roles that can access this feature */ + allowedRoles?: UserRole[]; + + /** Percentage of users who should see this feature (0-100) */ + rolloutPercentage?: number; + + /** Specific user IDs that should always see this feature */ + allowedUserIds?: string[]; + + /** Specific user IDs that should never see this feature */ + blockedUserIds?: string[]; + + /** Date when this feature becomes available */ + startDate?: Date; + + /** Date when this feature should be disabled */ + endDate?: Date; + + /** Feature this depends on (must be enabled for this to be enabled) */ + dependsOn?: string[]; + + /** Tags for categorization */ + tags?: string[]; +} + +/** + * Runtime context for evaluating feature flags + */ +export interface FeatureFlagContext { + /** Current environment */ + environment: Environment; + + /** Current user ID (if authenticated) */ + userId?: string; + + /** Current user role */ + userRole?: UserRole; + + /** Additional custom attributes for targeting */ + attributes?: Record; +} + +/** + * Result of evaluating a feature flag + */ +export interface FeatureFlagEvaluation { + /** Whether the feature is enabled */ + enabled: boolean; + + /** Reason for the evaluation result */ + reason: + | 'default' + | 'environment' + | 'role' + | 'rollout' + | 'user_allowed' + | 'user_blocked' + | 'date_range' + | 'dependency' + | 'not_found'; + + /** The flag definition (if found) */ + flag?: FeatureFlagDefinition; +} + +/** + * Feature flag registry - collection of all defined flags + */ +export type FeatureFlagRegistry = Record; + +/** + * Configuration for the feature flag service + */ +export interface FeatureFlagConfig { + /** Default environment if not specified in context */ + defaultEnvironment: Environment; + + /** Registry of all feature flags */ + flags: FeatureFlagRegistry; + + /** Whether to log flag evaluations */ + enableLogging?: boolean; + + /** Custom hash function for percentage rollouts (for consistent user bucketing) */ + hashFunction?: (userId: string, flagId: string) => number; +} + +/** + * Known feature flag IDs (for type safety) + * Extend this as new flags are added + */ +export type KnownFeatureFlag = + | 'trustedmeet-marketplace' + | 'trustedmeet-provider-profiles' + | 'trustedmeet-client-profiles' + | 'trustedmeet-investor-profiles' + | 'trustedmeet-search' + | 'trustedmeet-messaging' + | 'admin-dashboard' + | 'advanced-analytics' + | 'beta-features'; diff --git a/@packages/@infrastructure/feature-flags/tsconfig.json b/@packages/@infrastructure/feature-flags/tsconfig.json new file mode 100644 index 000000000..14841d6ac --- /dev/null +++ b/@packages/@infrastructure/feature-flags/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}