Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
5fe537b93d ci: Bump version to 0.12.22 [skip ci] 2026-01-29 12:26:33 +05:00
61f24305fb ADR-024 Feature Flagging Strategy
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
2026-01-28 23:23:45 -08:00
24 changed files with 3187 additions and 61 deletions

View File

@@ -128,3 +128,35 @@ GENERATE_SOURCE_MAPS=true
SENTRY_AUTH_TOKEN=
# URL of your Bugsink instance (for source map uploads)
SENTRY_URL=https://bugsink.projectium.com
# ===================
# Feature Flags (ADR-024)
# ===================
# Feature flags control the availability of features at runtime.
# All flags default to disabled (false) when not set or set to any value other than 'true'.
# Set to 'true' to enable a feature.
#
# Backend flags use: FEATURE_SNAKE_CASE
# Frontend flags use: VITE_FEATURE_SNAKE_CASE (VITE_ prefix required for client-side access)
#
# Lifecycle:
# 1. Add flag with default false
# 2. Enable via env var when ready for testing/rollout
# 3. Remove conditional code when feature is fully rolled out
# 4. Remove flag from config within 3 months of full rollout
#
# See: docs/adr/0024-feature-flagging-strategy.md
# Backend Feature Flags
# FEATURE_BUGSINK_SYNC=false # Enable Bugsink error sync integration
# FEATURE_ADVANCED_RBAC=false # Enable advanced RBAC features
# FEATURE_NEW_DASHBOARD=false # Enable new dashboard experience
# FEATURE_BETA_RECIPES=false # Enable beta recipe features
# FEATURE_EXPERIMENTAL_AI=false # Enable experimental AI features
# FEATURE_DEBUG_MODE=false # Enable debug mode for development
# Frontend Feature Flags (VITE_ prefix required)
# VITE_FEATURE_NEW_DASHBOARD=false # Enable new dashboard experience
# VITE_FEATURE_BETA_RECIPES=false # Enable beta recipe features
# VITE_FEATURE_EXPERIMENTAL_AI=false # Enable experimental AI features
# VITE_FEATURE_DEBUG_MODE=false # Enable debug mode for development

View File

@@ -78,25 +78,27 @@ Ask before assuming. Never assume:
### Key Patterns (with file locations)
| Pattern | ADR | Implementation | File |
| ------------------ | ------- | ------------------------------------------------- | ---------------------------------- |
| Error Handling | ADR-001 | `handleDbError()`, throw `NotFoundError` | `src/services/db/errors.db.ts` |
| Repository Methods | ADR-034 | `get*` (throws), `find*` (null), `list*` (array) | `src/services/db/*.db.ts` |
| API Responses | ADR-028 | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
| Transactions | ADR-002 | `withTransaction(async (client) => {...})` | `src/services/db/connection.db.ts` |
| Pattern | ADR | Implementation | File |
| ------------------ | ------- | ------------------------------------------------- | ------------------------------------- |
| Error Handling | ADR-001 | `handleDbError()`, throw `NotFoundError` | `src/services/db/errors.db.ts` |
| Repository Methods | ADR-034 | `get*` (throws), `find*` (null), `list*` (array) | `src/services/db/*.db.ts` |
| API Responses | ADR-028 | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
| Transactions | ADR-002 | `withTransaction(async (client) => {...})` | `src/services/db/connection.db.ts` |
| Feature Flags | ADR-024 | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
### Key Files Quick Access
| Purpose | File |
| ----------------- | -------------------------------- |
| Express app | `server.ts` |
| Environment | `src/config/env.ts` |
| Routes | `src/routes/*.routes.ts` |
| Repositories | `src/services/db/*.db.ts` |
| Workers | `src/services/workers.server.ts` |
| Queues | `src/services/queues.server.ts` |
| PM2 Config (Dev) | `ecosystem.dev.config.cjs` |
| PM2 Config (Prod) | `ecosystem.config.cjs` |
| Purpose | File |
| ----------------- | ------------------------------------- |
| Express app | `server.ts` |
| Environment | `src/config/env.ts` |
| Routes | `src/routes/*.routes.ts` |
| Repositories | `src/services/db/*.db.ts` |
| Workers | `src/services/workers.server.ts` |
| Queues | `src/services/queues.server.ts` |
| Feature Flags | `src/services/featureFlags.server.ts` |
| PM2 Config (Dev) | `ecosystem.dev.config.cjs` |
| PM2 Config (Prod) | `ecosystem.config.cjs` |
---

View File

@@ -1,18 +1,333 @@
# ADR-024: Feature Flagging Strategy
**Date**: 2025-12-12
**Status**: Accepted
**Implemented**: 2026-01-28
**Implementation Plan**: [2026-01-28-adr-024-feature-flags-implementation.md](../plans/2026-01-28-adr-024-feature-flags-implementation.md)
**Status**: Proposed
## Implementation Summary
Feature flag infrastructure fully implemented with 89 new tests (all passing). Total test suite: 3,616 tests passing.
**Backend**:
- Zod-validated schema in `src/config/env.ts` with 6 feature flags
- Service module `src/services/featureFlags.server.ts` with `isFeatureEnabled()`, `getFeatureFlags()`, `getEnabledFeatureFlags()`
- Admin endpoint `GET /api/v1/admin/feature-flags` (requires admin authentication)
- Convenience exports for direct boolean access
**Frontend**:
- Config section in `src/config.ts` with `VITE_FEATURE_*` environment variables
- Type declarations in `src/vite-env.d.ts`
- React hook `useFeatureFlag()` and `useAllFeatureFlags()` in `src/hooks/useFeatureFlag.ts`
- Declarative component `<FeatureFlag>` in `src/components/FeatureFlag.tsx`
**Current Flags**: `bugsinkSync`, `advancedRbac`, `newDashboard`, `betaRecipes`, `experimentalAi`, `debugMode`
---
## Context
As the application grows, there is no way to roll out new features to a subset of users (e.g., for beta testing) or to quickly disable a problematic feature in production without a full redeployment.
Application lacks controlled feature rollout capability. No mechanism for beta testing, quick production disablement, or gradual rollouts without full redeployment. Need type-safe, configuration-based system integrating with ADR-007 Zod validation.
## Decision
We will implement a feature flagging system. This could start with a simple configuration-based approach (defined in `ADR-007`) and evolve to use a dedicated service like **Flagsmith** or **LaunchDarkly**. This ADR will define how feature flags are created, managed, and checked in both the backend and frontend code.
Implement environment-variable-based feature flag system. Backend: Zod-validated schema in `src/config/env.ts` + dedicated service. Frontend: Vite env vars + React hook + declarative component. All flags default `false` (opt-in model). Future migration path to Flagsmith/LaunchDarkly preserved via abstraction layer.
## Consequences
**Positive**: Decouples feature releases from code deployments, reducing risk and allowing for more controlled, gradual rollouts and A/B testing. Enables easier experimentation and faster iteration.
**Negative**: Adds complexity to the codebase with conditional logic around features. Requires careful management of feature flag states to avoid technical debt.
- **Positive**: Decouples releases from deployments reduced risk, gradual rollouts, A/B testing capability
- **Negative**: Conditional logic complexity → requires sunset policy (3-month max after full rollout)
- **Neutral**: Restart required for flag changes (acceptable for current scale, external service removes this constraint)
---
## Implementation Details
### Architecture Overview
```text
Environment Variables (FEATURE_*, VITE_FEATURE_*)
├── Backend ──► src/config/env.ts (Zod) ──► src/services/featureFlags.server.ts
│ │
│ ┌──────────┴──────────┐
│ │ │
│ isFeatureEnabled() getAllFeatureFlags()
│ │
│ Routes/Services
└── Frontend ─► src/config.ts ──► src/hooks/useFeatureFlag.ts
┌──────────────┼──────────────┐
│ │ │
useFeatureFlag() useAllFeatureFlags() <FeatureFlag>
│ Component
Components
```
### File Structure
| File | Purpose | Layer |
| ------------------------------------- | ------------------------ | ---------------- |
| `src/config/env.ts` | Zod schema + env loading | Backend config |
| `src/services/featureFlags.server.ts` | Flag access service | Backend runtime |
| `src/config.ts` | Vite env parsing | Frontend config |
| `src/vite-env.d.ts` | TypeScript declarations | Frontend types |
| `src/hooks/useFeatureFlag.ts` | React hook | Frontend runtime |
| `src/components/FeatureFlag.tsx` | Declarative wrapper | Frontend UI |
### Naming Convention
| Context | Pattern | Example |
| ------------------- | ------------------------- | ---------------------------------- |
| Backend env var | `FEATURE_SNAKE_CASE` | `FEATURE_NEW_DASHBOARD` |
| Frontend env var | `VITE_FEATURE_SNAKE_CASE` | `VITE_FEATURE_NEW_DASHBOARD` |
| Config property | `camelCase` | `config.featureFlags.newDashboard` |
| Hook/function param | `camelCase` literal | `isFeatureEnabled('newDashboard')` |
### Backend Implementation
#### Schema Definition (`src/config/env.ts`)
```typescript
/**
* Feature flags schema (ADR-024).
* All flags default false (disabled) for safety.
*/
const featureFlagsSchema = z.object({
newDashboard: booleanString(false), // FEATURE_NEW_DASHBOARD
betaRecipes: booleanString(false), // FEATURE_BETA_RECIPES
experimentalAi: booleanString(false), // FEATURE_EXPERIMENTAL_AI
debugMode: booleanString(false), // FEATURE_DEBUG_MODE
});
// In loadEnvVars():
featureFlags: {
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
betaRecipes: process.env.FEATURE_BETA_RECIPES,
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
debugMode: process.env.FEATURE_DEBUG_MODE,
},
```
#### Service Module (`src/services/featureFlags.server.ts`)
```typescript
import { config, isDevelopment } from '../config/env';
import { logger } from './logger.server';
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Check feature flag state. Logs in development mode.
*/
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
const enabled = config.featureFlags[flagName];
if (isDevelopment) {
logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
}
return enabled;
}
/**
* Get all flags (admin/debug endpoints).
*/
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return { ...config.featureFlags };
}
// Convenience exports (evaluated once at startup)
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
```
#### Usage in Routes
```typescript
import { isFeatureEnabled } from '../services/featureFlags.server';
router.get('/dashboard', async (req, res) => {
if (isFeatureEnabled('newDashboard')) {
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
}
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});
```
### Frontend Implementation
#### Config (`src/config.ts`)
```typescript
const config = {
// ... existing sections ...
featureFlags: {
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
},
};
```
#### Type Declarations (`src/vite-env.d.ts`)
```typescript
interface ImportMetaEnv {
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
readonly VITE_FEATURE_BETA_RECIPES?: string;
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
readonly VITE_FEATURE_DEBUG_MODE?: string;
}
```
#### React Hook (`src/hooks/useFeatureFlag.ts`)
```typescript
import { useMemo } from 'react';
import config from '../config';
export type FeatureFlagName = keyof typeof config.featureFlags;
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
return useMemo(() => config.featureFlags[flagName], [flagName]);
}
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return useMemo(() => ({ ...config.featureFlags }), []);
}
```
#### Declarative Component (`src/components/FeatureFlag.tsx`)
```typescript
import { ReactNode } from 'react';
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';
interface FeatureFlagProps {
name: FeatureFlagName;
children: ReactNode;
fallback?: ReactNode;
}
export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
const isEnabled = useFeatureFlag(name);
return <>{isEnabled ? children : fallback}</>;
}
```
#### Usage in Components
```tsx
// Declarative approach
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</FeatureFlag>;
// Hook approach (for logic beyond rendering)
const isNewDashboard = useFeatureFlag('newDashboard');
useEffect(() => {
if (isNewDashboard) analytics.track('new_dashboard_viewed');
}, [isNewDashboard]);
```
### Testing Patterns
#### Backend Test Setup
```typescript
// Reset modules to test different flag states
beforeEach(() => {
vi.resetModules();
process.env.FEATURE_NEW_DASHBOARD = 'true';
});
// src/services/featureFlags.server.test.ts
describe('isFeatureEnabled', () => {
it('returns false for disabled flags', () => {
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
});
```
#### Frontend Test Setup
```typescript
// Mock config module
vi.mock('../config', () => ({
default: {
featureFlags: {
newDashboard: true,
betaRecipes: false,
},
},
}));
// Component test
describe('FeatureFlag', () => {
it('renders fallback when disabled', () => {
render(
<FeatureFlag name="betaRecipes" fallback={<div>Old</div>}>
<div>New</div>
</FeatureFlag>
);
expect(screen.getByText('Old')).toBeInTheDocument();
});
});
```
### Flag Lifecycle
| Phase | Actions |
| ---------- | -------------------------------------------------------------------------------------------- |
| **Add** | 1. Add to both schemas (backend + frontend) 2. Default `false` 3. Document in `.env.example` |
| **Enable** | Set env var `='true'` → restart application |
| **Remove** | 1. Remove conditional code 2. Remove from schemas 3. Remove env vars |
| **Sunset** | Max 3 months after full rollout → remove flag |
### Admin Endpoint (Optional)
```typescript
// GET /api/v1/admin/feature-flags (admin-only)
router.get('/feature-flags', requireAdmin, async (req, res) => {
sendSuccess(res, { flags: getAllFeatureFlags() });
});
```
### Integration with ADR-007
Feature flags extend existing Zod configuration pattern:
- **Validation**: Same `booleanString()` transform used by other config
- **Loading**: Same `loadEnvVars()` function loads `FEATURE_*` vars
- **Type Safety**: `FeatureFlagName` type derived from config schema
- **Fail-Fast**: Invalid flag values fail at startup (Zod validation)
### Future Migration Path
Current implementation abstracts flag access via `isFeatureEnabled()` function and `useFeatureFlag()` hook. External service migration requires:
1. Replace implementation internals of these functions
2. Add API client for Flagsmith/LaunchDarkly
3. No changes to consuming code (routes/components)
### Explicitly Out of Scope
- External service integration (Flagsmith/LaunchDarkly)
- Database-stored flags
- Real-time flag updates (WebSocket/SSE)
- User-specific flags (A/B testing percentages)
- Flag inheritance/hierarchy
- Flag audit logging
### Key Files Reference
| Action | Files |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| Add new flag | `src/config/env.ts`, `src/config.ts`, `src/vite-env.d.ts`, `.env.example` |
| Check flag (backend) | Import from `src/services/featureFlags.server.ts` |
| Check flag (frontend) | Import hook from `src/hooks/useFeatureFlag.ts` or component from `src/components/FeatureFlag.tsx` |
| Test flag behavior | Mock via `vi.resetModules()` (backend) or `vi.mock('../config')` (frontend) |

View File

@@ -15,9 +15,9 @@ This document tracks the implementation status and estimated effort for all Arch
| Status | Count |
| ---------------------------- | ----- |
| Accepted (Fully Implemented) | 41 |
| Accepted (Fully Implemented) | 42 |
| Partially Implemented | 2 |
| Proposed (Not Started) | 13 |
| Proposed (Not Started) | 12 |
| Superseded | 1 |
---
@@ -78,16 +78,16 @@ This document tracks the implementation status and estimated effort for all Arch
### Category 6: Deployment & Operations
| ADR | Title | Status | Effort | Notes |
| -------------------------------------------------------------- | ------------------ | -------- | ------ | -------------------------- |
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
| ADR | Title | Status | Effort | Notes |
| -------------------------------------------------------------- | ------------------ | -------- | ------ | ------------------------ |
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Accepted | - | Fully implemented |
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
### Category 7: Frontend / User Interface
@@ -134,15 +134,14 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
| Priority | ADR | Title | Status | Effort | Rationale |
| -------- | ------- | ------------------------ | -------- | ------ | ------------------------------------ |
| 1 | ADR-024 | Feature Flags | Proposed | M | Safer deployments, A/B testing |
| 2 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
| 3 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
| 4 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
| 5 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
| 6 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
| 7 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
| 8 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
| 9 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
| 1 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
| 2 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
| 3 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
| 4 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
| 5 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
| 6 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
| 7 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
| 8 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
---
@@ -150,6 +149,7 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
| Date | ADR | Change |
| ---------- | ------- | ----------------------------------------------------------------------------------- |
| 2026-01-28 | ADR-024 | Fully implemented - Backend/frontend feature flags, 89 tests, admin endpoint |
| 2026-01-28 | ADR-057 | Created - Test remediation documentation for ADR-008 Phase 2 migration |
| 2026-01-28 | ADR-013 | Marked as Superseded by ADR-023 |
| 2026-01-27 | ADR-008 | Test path migration complete - 23 files, ~70 paths updated, 274->345 tests passing |

View File

@@ -4,16 +4,17 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
## Quick Reference
| Pattern | Key Function/Class | Import From |
| ------------------ | ------------------------------------------------- | ---------------------------------- |
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
| Caching | `cacheService` | `src/services/cache.server.ts` |
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
| Pattern | Key Function/Class | Import From |
| ------------------ | ------------------------------------------------- | ------------------------------------- |
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
| Caching | `cacheService` | `src/services/cache.server.ts` |
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
---
@@ -27,6 +28,7 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
- [Authentication](#authentication)
- [Caching](#caching)
- [Background Jobs](#background-jobs)
- [Feature Flags](#feature-flags)
---
@@ -491,6 +493,153 @@ const flyerWorker = new Worker(
---
## Feature Flags
**ADR**: [ADR-024](../adr/0024-feature-flagging-strategy.md)
Feature flags enable controlled feature rollout, A/B testing, and quick production disablement without redeployment. All flags default to `false` (opt-in model).
### Backend Usage
```typescript
import { isFeatureEnabled, getFeatureFlags } from '../services/featureFlags.server';
// Check a specific flag in route handler
router.get('/dashboard', async (req, res) => {
if (isFeatureEnabled('newDashboard')) {
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
}
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});
// Check flag in service layer
function processFlyer(flyer: Flyer): ProcessedFlyer {
if (isFeatureEnabled('experimentalAi')) {
return processWithExperimentalAi(flyer);
}
return processWithStandardAi(flyer);
}
// Get all flags (admin endpoint)
router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
sendSuccess(res, { flags: getFeatureFlags() });
});
```
### Frontend Usage
```tsx
import { useFeatureFlag, useAllFeatureFlags } from '../hooks/useFeatureFlag';
import { FeatureFlag } from '../components/FeatureFlag';
// Hook approach - for logic beyond rendering
function Dashboard() {
const isNewDashboard = useFeatureFlag('newDashboard');
useEffect(() => {
if (isNewDashboard) {
analytics.track('new_dashboard_viewed');
}
}, [isNewDashboard]);
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}
// Declarative component approach
function App() {
return (
<FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</FeatureFlag>
);
}
// Debug panel showing all flags
function DebugPanel() {
const flags = useAllFeatureFlags();
return (
<ul>
{Object.entries(flags).map(([name, enabled]) => (
<li key={name}>
{name}: {enabled ? 'ON' : 'OFF'}
</li>
))}
</ul>
);
}
```
### Adding a New Flag
1. **Backend** (`src/config/env.ts`):
```typescript
// In featureFlagsSchema
myNewFeature: booleanString(false), // FEATURE_MY_NEW_FEATURE
// In loadEnvVars()
myNewFeature: process.env.FEATURE_MY_NEW_FEATURE,
```
2. **Frontend** (`src/config.ts` and `src/vite-env.d.ts`):
```typescript
// In config.ts featureFlags section
myNewFeature: import.meta.env.VITE_FEATURE_MY_NEW_FEATURE === 'true',
// In vite-env.d.ts
readonly VITE_FEATURE_MY_NEW_FEATURE?: string;
```
3. **Environment** (`.env.example`):
```bash
# FEATURE_MY_NEW_FEATURE=false
# VITE_FEATURE_MY_NEW_FEATURE=false
```
### Testing Feature Flags
```typescript
// Backend - reset modules to test different states
beforeEach(() => {
vi.resetModules();
process.env.FEATURE_NEW_DASHBOARD = 'true';
});
// Frontend - mock config module
vi.mock('../config', () => ({
default: {
featureFlags: {
newDashboard: true,
betaRecipes: false,
},
},
}));
```
### Flag Lifecycle
| Phase | Actions |
| ---------- | -------------------------------------------------------------- |
| **Add** | Add to schemas (backend + frontend), default `false`, document |
| **Enable** | Set env var `='true'`, restart application |
| **Remove** | Remove conditional code, remove from schemas, remove env vars |
| **Sunset** | Max 3 months after full rollout - remove flag |
### Current Flags
| Flag | Backend Env Var | Frontend Env Var | Purpose |
| ---------------- | ------------------------- | ------------------------------ | ------------------------ |
| `bugsinkSync` | `FEATURE_BUGSINK_SYNC` | `VITE_FEATURE_BUGSINK_SYNC` | Bugsink error sync |
| `advancedRbac` | `FEATURE_ADVANCED_RBAC` | `VITE_FEATURE_ADVANCED_RBAC` | Advanced RBAC features |
| `newDashboard` | `FEATURE_NEW_DASHBOARD` | `VITE_FEATURE_NEW_DASHBOARD` | New dashboard experience |
| `betaRecipes` | `FEATURE_BETA_RECIPES` | `VITE_FEATURE_BETA_RECIPES` | Beta recipe features |
| `experimentalAi` | `FEATURE_EXPERIMENTAL_AI` | `VITE_FEATURE_EXPERIMENTAL_AI` | Experimental AI features |
| `debugMode` | `FEATURE_DEBUG_MODE` | `VITE_FEATURE_DEBUG_MODE` | Debug mode |
---
## Related Documentation
- [ADR Index](../adr/index.md) - All architecture decision records

View File

@@ -0,0 +1,849 @@
# ADR-024 Implementation Plan: Feature Flagging Strategy
**Date**: 2026-01-28
**Type**: Technical Implementation Plan
**Related**: [ADR-024: Feature Flagging Strategy](../adr/0024-feature-flagging-strategy.md), [ADR-007: Configuration and Secrets Management](../adr/0007-configuration-and-secrets-management.md)
**Status**: Ready for Implementation
---
## Project Overview
Implement a simple, configuration-based feature flag system that integrates with the existing Zod-validated configuration in `src/config/env.ts`. The system will support both backend and frontend feature flags through environment variables, with type-safe access patterns and helper utilities.
### Key Success Criteria
1. Feature flags accessible via type-safe API on both backend and frontend
2. Zero runtime overhead when flag is disabled (compile-time elimination where possible)
3. Consistent naming convention (environment variables and code access)
4. Graceful degradation (missing flag defaults to disabled)
5. Easy migration path to external service (Flagsmith/LaunchDarkly) in the future
6. Full test coverage with mocking utilities
### Estimated Total Effort
| Phase | Estimate |
| --------------------------------- | -------------- |
| Phase 1: Backend Infrastructure | 3-5 hours |
| Phase 2: Frontend Infrastructure | 2-3 hours |
| Phase 3: Documentation & Examples | 1-2 hours |
| **Total** | **6-10 hours** |
---
## Current State Analysis
### Backend Configuration (`src/config/env.ts`)
- Zod-based schema validation at startup
- Organized into logical groups (database, redis, auth, smtp, ai, etc.)
- Helper exports for service availability (`isSmtpConfigured`, `isAiConfigured`, etc.)
- Environment helpers (`isProduction`, `isTest`, `isDevelopment`)
- Fail-fast on invalid configuration
### Frontend Configuration (`src/config.ts`)
- Uses `import.meta.env` (Vite environment variables)
- Organized into sections (app, google, sentry)
- Boolean parsing for string env vars
- Type declarations in `src/vite-env.d.ts`
### Existing Patterns to Follow
```typescript
// Backend - service availability check pattern
export const isSmtpConfigured =
!!config.smtp.host && !!config.smtp.user && !!config.smtp.pass;
// Frontend - boolean parsing pattern
enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
```
---
## Task Breakdown
### Phase 1: Backend Feature Flag Infrastructure
#### [1.1] Define Feature Flag Schema in env.ts
**Complexity**: Low
**Estimate**: 30-45 minutes
**Dependencies**: None
**Parallelizable**: Yes
**Description**: Add a new `featureFlags` section to the Zod schema in `src/config/env.ts`.
**Acceptance Criteria**:
- [ ] New `featureFlagsSchema` Zod object defined
- [ ] Schema supports boolean flags with defaults to `false` (opt-in model)
- [ ] Schema added to main `envSchema` object
- [ ] Type exported as part of `EnvConfig`
**Implementation Details**:
```typescript
// src/config/env.ts
/**
* Feature flags configuration schema (ADR-024).
* All flags default to false (disabled) for safety.
* Set to 'true' in environment to enable.
*/
const featureFlagsSchema = z.object({
// Example flags - replace with actual feature flags as needed
newDashboard: booleanString(false), // FEATURE_NEW_DASHBOARD
betaRecipes: booleanString(false), // FEATURE_BETA_RECIPES
experimentalAi: booleanString(false), // FEATURE_EXPERIMENTAL_AI
debugMode: booleanString(false), // FEATURE_DEBUG_MODE
});
// In loadEnvVars():
featureFlags: {
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
betaRecipes: process.env.FEATURE_BETA_RECIPES,
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
debugMode: process.env.FEATURE_DEBUG_MODE,
},
```
**Risks/Notes**:
- Naming convention: `FEATURE_*` prefix for all feature flag env vars
- Default to `false` ensures features are opt-in, preventing accidental exposure
---
#### [1.2] Create Feature Flag Service Module
**Complexity**: Medium
**Estimate**: 1-2 hours
**Dependencies**: [1.1]
**Parallelizable**: No (depends on 1.1)
**Description**: Create a dedicated service module for feature flag access with helper functions.
**File**: `src/services/featureFlags.server.ts`
**Acceptance Criteria**:
- [ ] `isFeatureEnabled(flagName)` function for checking flags
- [ ] `getAllFeatureFlags()` function for debugging/admin endpoints
- [ ] Type-safe flag name parameter (union type or enum)
- [ ] Exported helper booleans for common flags (similar to `isSmtpConfigured`)
- [ ] Logging when feature flag is checked in development mode
**Implementation Details**:
```typescript
// src/services/featureFlags.server.ts
import { config, isDevelopment } from '../config/env';
import { logger } from './logger.server';
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Check if a feature flag is enabled.
* @param flagName - The name of the feature flag to check
* @returns boolean indicating if the feature is enabled
*/
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
const enabled = config.featureFlags[flagName];
if (isDevelopment) {
logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
}
return enabled;
}
/**
* Get all feature flags and their current states.
* Useful for debugging and admin endpoints.
*/
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return { ...config.featureFlags };
}
// Convenience exports for common flag checks
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
export const isExperimentalAiEnabled = config.featureFlags.experimentalAi;
export const isDebugModeEnabled = config.featureFlags.debugMode;
```
**Risks/Notes**:
- Keep logging minimal to avoid performance impact
- Convenience exports are evaluated once at startup (not dynamic)
---
#### [1.3] Add Admin Endpoint for Feature Flag Status
**Complexity**: Low
**Estimate**: 30-45 minutes
**Dependencies**: [1.2]
**Parallelizable**: No (depends on 1.2)
**Description**: Add an admin/health endpoint to view current feature flag states.
**File**: `src/routes/admin.routes.ts` (or `stats.routes.ts` if admin routes don't exist)
**Acceptance Criteria**:
- [ ] `GET /api/v1/admin/feature-flags` endpoint (admin-only)
- [ ] Returns JSON object with all flags and their states
- [ ] Requires admin authentication
- [ ] Endpoint documented in Swagger
**Implementation Details**:
```typescript
// In appropriate routes file
router.get('/feature-flags', requireAdmin, async (req, res) => {
const flags = getAllFeatureFlags();
sendSuccess(res, { flags });
});
```
**Risks/Notes**:
- Ensure endpoint is protected (admin-only)
- Consider caching response if called frequently
---
#### [1.4] Backend Unit Tests
**Complexity**: Medium
**Estimate**: 1-2 hours
**Dependencies**: [1.1], [1.2]
**Parallelizable**: Yes (can start after 1.1, in parallel with 1.3)
**Description**: Write unit tests for feature flag configuration and service.
**Files**:
- `src/config/env.test.ts` (add feature flag tests)
- `src/services/featureFlags.server.test.ts` (new file)
**Acceptance Criteria**:
- [ ] Test default values (all false)
- [ ] Test parsing 'true'/'false' strings
- [ ] Test `isFeatureEnabled()` function
- [ ] Test `getAllFeatureFlags()` function
- [ ] Test type safety (TypeScript compile-time checks)
**Implementation Details**:
```typescript
// src/config/env.test.ts - add to existing file
describe('featureFlags configuration', () => {
it('should default all feature flags to false', async () => {
setValidEnv();
const { config } = await import('./env');
expect(config.featureFlags.newDashboard).toBe(false);
expect(config.featureFlags.betaRecipes).toBe(false);
});
it('should parse FEATURE_NEW_DASHBOARD as true when set', async () => {
setValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { config } = await import('./env');
expect(config.featureFlags.newDashboard).toBe(true);
});
});
// src/services/featureFlags.server.test.ts - new file
describe('featureFlags service', () => {
describe('isFeatureEnabled', () => {
it('should return false for disabled flags', () => {
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
// ... more tests
});
});
```
---
### Phase 2: Frontend Feature Flag Infrastructure
#### [2.1] Add Frontend Feature Flag Config
**Complexity**: Low
**Estimate**: 30-45 minutes
**Dependencies**: None (can run in parallel with Phase 1)
**Parallelizable**: Yes
**Description**: Add feature flags to the frontend config module.
**Files**:
- `src/config.ts` - Add featureFlags section
- `src/vite-env.d.ts` - Add type declarations
**Acceptance Criteria**:
- [ ] Feature flags section added to `src/config.ts`
- [ ] TypeScript declarations updated in `vite-env.d.ts`
- [ ] Boolean parsing consistent with existing pattern
- [ ] Default to false when env var not set
**Implementation Details**:
```typescript
// src/config.ts
const config = {
// ... existing sections ...
/**
* Feature flags for conditional feature rendering (ADR-024).
* All flags default to false (disabled) when not explicitly set.
*/
featureFlags: {
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
},
};
// src/vite-env.d.ts
interface ImportMetaEnv {
// ... existing declarations ...
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
readonly VITE_FEATURE_BETA_RECIPES?: string;
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
readonly VITE_FEATURE_DEBUG_MODE?: string;
}
```
---
#### [2.2] Create useFeatureFlag React Hook
**Complexity**: Medium
**Estimate**: 1-1.5 hours
**Dependencies**: [2.1]
**Parallelizable**: No (depends on 2.1)
**Description**: Create a React hook for checking feature flags in components.
**File**: `src/hooks/useFeatureFlag.ts`
**Acceptance Criteria**:
- [ ] `useFeatureFlag(flagName)` hook returns boolean
- [ ] Type-safe flag name parameter
- [ ] Memoized to prevent unnecessary re-renders
- [ ] Optional `FeatureFlag` component for conditional rendering
**Implementation Details**:
```typescript
// src/hooks/useFeatureFlag.ts
import { useMemo } from 'react';
import config from '../config';
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Hook to check if a feature flag is enabled.
*
* @param flagName - The name of the feature flag to check
* @returns boolean indicating if the feature is enabled
*
* @example
* const isNewDashboard = useFeatureFlag('newDashboard');
* if (isNewDashboard) {
* return <NewDashboard />;
* }
*/
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
return useMemo(() => config.featureFlags[flagName], [flagName]);
}
/**
* Get all feature flags (useful for debugging).
*/
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return useMemo(() => ({ ...config.featureFlags }), []);
}
```
---
#### [2.3] Create FeatureFlag Component
**Complexity**: Low
**Estimate**: 30-45 minutes
**Dependencies**: [2.2]
**Parallelizable**: No (depends on 2.2)
**Description**: Create a declarative component for feature flag conditional rendering.
**File**: `src/components/FeatureFlag.tsx`
**Acceptance Criteria**:
- [ ] `<FeatureFlag name="flagName">` component
- [ ] Children rendered only when flag is enabled
- [ ] Optional `fallback` prop for disabled state
- [ ] TypeScript-enforced flag names
**Implementation Details**:
```typescript
// src/components/FeatureFlag.tsx
import { ReactNode } from 'react';
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';
interface FeatureFlagProps {
/** The name of the feature flag to check */
name: FeatureFlagName;
/** Content to render when feature is enabled */
children: ReactNode;
/** Optional content to render when feature is disabled */
fallback?: ReactNode;
}
/**
* Conditionally renders children based on feature flag state.
*
* @example
* <FeatureFlag name="newDashboard" fallback={<OldDashboard />}>
* <NewDashboard />
* </FeatureFlag>
*/
export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
const isEnabled = useFeatureFlag(name);
return <>{isEnabled ? children : fallback}</>;
}
```
---
#### [2.4] Frontend Unit Tests
**Complexity**: Medium
**Estimate**: 1-1.5 hours
**Dependencies**: [2.1], [2.2], [2.3]
**Parallelizable**: No (depends on previous frontend tasks)
**Description**: Write unit tests for frontend feature flag utilities.
**Files**:
- `src/config.test.ts` (add feature flag tests)
- `src/hooks/useFeatureFlag.test.ts` (new file)
- `src/components/FeatureFlag.test.tsx` (new file)
**Acceptance Criteria**:
- [ ] Test config structure includes featureFlags
- [ ] Test default values (all false)
- [ ] Test hook returns correct values
- [ ] Test component renders/hides children correctly
- [ ] Test fallback rendering
**Implementation Details**:
```typescript
// src/hooks/useFeatureFlag.test.ts
import { renderHook } from '@testing-library/react';
import { useFeatureFlag, useAllFeatureFlags } from './useFeatureFlag';
describe('useFeatureFlag', () => {
it('should return false for disabled flags', () => {
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(false);
});
});
// src/components/FeatureFlag.test.tsx
import { render, screen } from '@testing-library/react';
import { FeatureFlag } from './FeatureFlag';
describe('FeatureFlag', () => {
it('should not render children when flag is disabled', () => {
render(
<FeatureFlag name="newDashboard">
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
});
it('should render fallback when flag is disabled', () => {
render(
<FeatureFlag name="newDashboard" fallback={<div>Old Feature</div>}>
<div>New Feature</div>
</FeatureFlag>
);
expect(screen.getByText('Old Feature')).toBeInTheDocument();
});
});
```
---
### Phase 3: Documentation & Integration
#### [3.1] Update ADR-024 with Implementation Status
**Complexity**: Low
**Estimate**: 30 minutes
**Dependencies**: [1.1], [1.2], [2.1], [2.2]
**Parallelizable**: Yes (can be done after core implementation)
**Description**: Update ADR-024 to mark it as implemented and add implementation details.
**File**: `docs/adr/0024-feature-flagging-strategy.md`
**Acceptance Criteria**:
- [ ] Status changed from "Proposed" to "Accepted"
- [ ] Implementation status section added
- [ ] Key files documented
- [ ] Usage examples included
---
#### [3.2] Update Environment Documentation
**Complexity**: Low
**Estimate**: 30 minutes
**Dependencies**: [1.1], [2.1]
**Parallelizable**: Yes
**Description**: Add feature flag environment variables to documentation.
**Files**:
- `docs/getting-started/ENVIRONMENT.md`
- `.env.example`
**Acceptance Criteria**:
- [ ] Feature flag variables documented in ENVIRONMENT.md
- [ ] New section "Feature Flags" added
- [ ] `.env.example` updated with commented feature flag examples
**Implementation Details**:
```bash
# .env.example addition
# ===================
# Feature Flags (ADR-024)
# ===================
# All feature flags default to disabled (false) when not set.
# Set to 'true' to enable a feature.
#
# FEATURE_NEW_DASHBOARD=false
# FEATURE_BETA_RECIPES=false
# FEATURE_EXPERIMENTAL_AI=false
# FEATURE_DEBUG_MODE=false
#
# Frontend equivalents (prefix with VITE_):
# VITE_FEATURE_NEW_DASHBOARD=false
# VITE_FEATURE_BETA_RECIPES=false
```
---
#### [3.3] Create CODE-PATTERNS Entry
**Complexity**: Low
**Estimate**: 30 minutes
**Dependencies**: All implementation tasks
**Parallelizable**: Yes
**Description**: Add feature flag usage patterns to CODE-PATTERNS.md.
**File**: `docs/development/CODE-PATTERNS.md`
**Acceptance Criteria**:
- [ ] Feature flag section added with examples
- [ ] Backend usage pattern documented
- [ ] Frontend usage pattern documented
- [ ] Testing pattern documented
---
#### [3.4] Update CLAUDE.md Quick Reference
**Complexity**: Low
**Estimate**: 15 minutes
**Dependencies**: All implementation tasks
**Parallelizable**: Yes
**Description**: Add feature flags to the CLAUDE.md quick reference tables.
**File**: `CLAUDE.md`
**Acceptance Criteria**:
- [ ] Feature flags added to "Key Patterns" table
- [ ] Reference to featureFlags service added
---
## Implementation Sequence
### Phase 1 (Backend) - Can Start Immediately
```text
[1.1] Schema ──────────┬──> [1.2] Service ──> [1.3] Admin Endpoint
└──> [1.4] Backend Tests (can start after 1.1)
```
### Phase 2 (Frontend) - Can Start Immediately (Parallel with Phase 1)
```text
[2.1] Config ──> [2.2] Hook ──> [2.3] Component ──> [2.4] Frontend Tests
```
### Phase 3 (Documentation) - After Implementation
```text
All Phase 1 & 2 Tasks ──> [3.1] ADR Update
├──> [3.2] Env Docs
├──> [3.3] Code Patterns
└──> [3.4] CLAUDE.md
```
---
## Critical Path
The minimum path to a working feature flag system:
1. **[1.1] Schema** (30 min) - Required for backend
2. **[1.2] Service** (1.5 hr) - Required for backend access
3. **[2.1] Frontend Config** (30 min) - Required for frontend
4. **[2.2] Hook** (1 hr) - Required for React integration
**Critical path duration**: ~3.5 hours
Non-critical but recommended:
- Admin endpoint (debugging)
- FeatureFlag component (developer convenience)
- Tests (quality assurance)
- Documentation (maintainability)
---
## Scope Recommendations
### MVP (Minimum Viable Implementation)
Include in initial implementation:
- [1.1] Backend schema with 2-3 example flags
- [1.2] Feature flag service
- [2.1] Frontend config
- [2.2] useFeatureFlag hook
- [1.4] Core backend tests
- [2.4] Core frontend tests
### Enhancements (Future Iterations)
Defer to follow-up work:
- Admin endpoint for flag visibility
- FeatureFlag component (nice-to-have)
- Dynamic flag updates without restart (requires external service)
- User-specific flags (A/B testing)
- Flag analytics/usage tracking
- Gradual rollout percentages
### Explicitly Out of Scope
- Integration with Flagsmith/LaunchDarkly (future ADR)
- Database-stored flags (requires schema changes)
- Real-time flag updates (WebSocket/SSE)
- Flag inheritance/hierarchy
- Flag audit logging
---
## Testing Strategy
### Backend Tests
| Test Type | Coverage Target | Location |
| ----------------- | ---------------------------------------- | ------------------------------------------ |
| Schema validation | Parse true/false, defaults | `src/config/env.test.ts` |
| Service functions | `isFeatureEnabled`, `getAllFeatureFlags` | `src/services/featureFlags.server.test.ts` |
| Integration | Admin endpoint (if added) | `src/routes/admin.routes.test.ts` |
### Frontend Tests
| Test Type | Coverage Target | Location |
| ------------------- | --------------------------- | ------------------------------------- |
| Config structure | featureFlags section exists | `src/config.test.ts` |
| Hook behavior | Returns correct values | `src/hooks/useFeatureFlag.test.ts` |
| Component rendering | Conditional children | `src/components/FeatureFlag.test.tsx` |
### Mocking Pattern for Tests
```typescript
// Backend - reset modules to test different flag states
beforeEach(() => {
vi.resetModules();
process.env.FEATURE_NEW_DASHBOARD = 'true';
});
// Frontend - mock config module
vi.mock('../config', () => ({
default: {
featureFlags: {
newDashboard: true,
betaRecipes: false,
},
},
}));
```
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
| ------------------------------------------- | ------ | ---------- | ------------------------------------------------------------- |
| Flag state inconsistency (backend/frontend) | Medium | Low | Use same env var naming, document sync requirements |
| Performance impact from flag checks | Low | Low | Flags cached at startup, no runtime DB calls |
| Stale flags after deployment | Medium | Medium | Document restart requirement, consider future dynamic loading |
| Feature creep (too many flags) | Medium | Medium | Require ADR for new flags, sunset policy |
| Missing flag causes crash | High | Low | Default to false, graceful degradation |
---
## Files to Create
| File | Purpose |
| ------------------------------------------ | ---------------------------- |
| `src/services/featureFlags.server.ts` | Backend feature flag service |
| `src/services/featureFlags.server.test.ts` | Backend tests |
| `src/hooks/useFeatureFlag.ts` | React hook for flag access |
| `src/hooks/useFeatureFlag.test.ts` | Hook tests |
| `src/components/FeatureFlag.tsx` | Declarative flag component |
| `src/components/FeatureFlag.test.tsx` | Component tests |
## Files to Modify
| File | Changes |
| -------------------------------------------- | ---------------------------------- |
| `src/config/env.ts` | Add featureFlagsSchema and loading |
| `src/config/env.test.ts` | Add feature flag tests |
| `src/config.ts` | Add featureFlags section |
| `src/config.test.ts` | Add feature flag tests |
| `src/vite-env.d.ts` | Add VITE*FEATURE*\* declarations |
| `.env.example` | Add feature flag examples |
| `docs/adr/0024-feature-flagging-strategy.md` | Update status and details |
| `docs/getting-started/ENVIRONMENT.md` | Document feature flag vars |
| `docs/development/CODE-PATTERNS.md` | Add usage patterns |
| `CLAUDE.md` | Add to quick reference |
---
## Verification Commands
After implementation, run these commands in the dev container:
```bash
# Type checking
podman exec -it flyer-crawler-dev npm run type-check
# Backend unit tests
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "featureFlag"
# Frontend tests (includes hook and component tests)
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "FeatureFlag"
# Full test suite
podman exec -it flyer-crawler-dev npm test
```
---
## Example Usage (Post-Implementation)
### Backend Route Handler
```typescript
// src/routes/flyers.routes.ts
import { isFeatureEnabled } from '../services/featureFlags.server';
router.get('/dashboard', async (req, res) => {
if (isFeatureEnabled('newDashboard')) {
// New dashboard logic
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
}
// Legacy dashboard
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});
```
### React Component
```tsx
// src/pages/Dashboard.tsx
import { FeatureFlag } from '../components/FeatureFlag';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
// Option 1: Declarative component
function Dashboard() {
return (
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</FeatureFlag>
);
}
// Option 2: Hook for logic
function DashboardWithLogic() {
const isNewDashboard = useFeatureFlag('newDashboard');
useEffect(() => {
if (isNewDashboard) {
analytics.track('new_dashboard_viewed');
}
}, [isNewDashboard]);
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}
```
---
## Implementation Notes
### Naming Convention
| Context | Pattern | Example |
| ---------------- | ------------------------- | ---------------------------------- |
| Backend env var | `FEATURE_SNAKE_CASE` | `FEATURE_NEW_DASHBOARD` |
| Frontend env var | `VITE_FEATURE_SNAKE_CASE` | `VITE_FEATURE_NEW_DASHBOARD` |
| Config property | `camelCase` | `config.featureFlags.newDashboard` |
| Type/Hook param | `camelCase` | `isFeatureEnabled('newDashboard')` |
### Flag Lifecycle
1. **Adding a flag**: Add to both schemas, set default to `false`, document
2. **Enabling a flag**: Set env var to `'true'`, restart application
3. **Removing a flag**: Remove conditional code first, then remove flag from schemas
4. **Sunset policy**: Flags should be removed within 3 months of full rollout
---
Last updated: 2026-01-28

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.12.21",
"version": "0.12.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.12.21",
"version": "0.12.22",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.12.21",
"version": "0.12.22",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -0,0 +1,378 @@
// src/components/FeatureFlag.test.tsx
/**
* Unit tests for the FeatureFlag component (ADR-024).
*
* These tests verify:
* - Component renders children when feature is enabled
* - Component hides children when feature is disabled
* - Component renders fallback when feature is disabled
* - Component returns null when disabled and no fallback provided
* - All feature flag names are properly handled
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the useFeatureFlag hook
const mockUseFeatureFlag = vi.fn();
vi.mock('../hooks/useFeatureFlag', () => ({
useFeatureFlag: (flagName: string) => mockUseFeatureFlag(flagName),
}));
// Import after mocking
import { FeatureFlag } from './FeatureFlag';
describe('FeatureFlag component', () => {
beforeEach(() => {
mockUseFeatureFlag.mockReset();
// Default to disabled
mockUseFeatureFlag.mockReturnValue(false);
});
describe('when feature is enabled', () => {
beforeEach(() => {
mockUseFeatureFlag.mockReturnValue(true);
});
it('should render children', () => {
render(
<FeatureFlag feature="newDashboard">
<div data-testid="new-feature">New Feature Content</div>
</FeatureFlag>,
);
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
expect(screen.getByText('New Feature Content')).toBeInTheDocument();
});
it('should not render fallback', () => {
render(
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<FeatureFlag feature="newDashboard">
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
</FeatureFlag>,
);
expect(screen.getByTestId('child-1')).toBeInTheDocument();
expect(screen.getByTestId('child-2')).toBeInTheDocument();
});
it('should render text content', () => {
render(<FeatureFlag feature="newDashboard">Just some text</FeatureFlag>);
expect(screen.getByText('Just some text')).toBeInTheDocument();
});
it('should call useFeatureFlag with correct flag name', () => {
render(
<FeatureFlag feature="betaRecipes">
<div>Content</div>
</FeatureFlag>,
);
expect(mockUseFeatureFlag).toHaveBeenCalledWith('betaRecipes');
});
});
describe('when feature is disabled', () => {
beforeEach(() => {
mockUseFeatureFlag.mockReturnValue(false);
});
it('should not render children', () => {
render(
<FeatureFlag feature="newDashboard">
<div data-testid="new-feature">New Feature Content</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
expect(screen.queryByText('New Feature Content')).not.toBeInTheDocument();
});
it('should render fallback when provided', () => {
render(
<FeatureFlag
feature="newDashboard"
fallback={<div data-testid="fallback">Legacy Feature</div>}
>
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
expect(screen.getByTestId('fallback')).toBeInTheDocument();
expect(screen.getByText('Legacy Feature')).toBeInTheDocument();
});
it('should render null when no fallback is provided', () => {
const { container } = render(
<FeatureFlag feature="newDashboard">
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
// Container should be empty (just the wrapper)
expect(container.innerHTML).toBe('');
});
it('should render complex fallback components', () => {
const FallbackComponent = () => (
<div data-testid="complex-fallback">
<h1>Legacy Dashboard</h1>
<p>This is the old version</p>
</div>
);
render(
<FeatureFlag feature="newDashboard" fallback={<FallbackComponent />}>
<div data-testid="new-feature">New Dashboard</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
expect(screen.getByTestId('complex-fallback')).toBeInTheDocument();
expect(screen.getByText('Legacy Dashboard')).toBeInTheDocument();
expect(screen.getByText('This is the old version')).toBeInTheDocument();
});
it('should render text fallback', () => {
render(
<FeatureFlag feature="newDashboard" fallback="Feature not available">
<div>New Feature</div>
</FeatureFlag>,
);
expect(screen.getByText('Feature not available')).toBeInTheDocument();
});
});
describe('with different feature flags', () => {
it('should work with newDashboard flag', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(
<FeatureFlag feature="newDashboard">
<div data-testid="dashboard">Dashboard</div>
</FeatureFlag>,
);
expect(mockUseFeatureFlag).toHaveBeenCalledWith('newDashboard');
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
});
it('should work with betaRecipes flag', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(
<FeatureFlag feature="betaRecipes">
<div data-testid="recipes">Recipes</div>
</FeatureFlag>,
);
expect(mockUseFeatureFlag).toHaveBeenCalledWith('betaRecipes');
expect(screen.getByTestId('recipes')).toBeInTheDocument();
});
it('should work with experimentalAi flag', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(
<FeatureFlag feature="experimentalAi">
<div data-testid="ai">AI Feature</div>
</FeatureFlag>,
);
expect(mockUseFeatureFlag).toHaveBeenCalledWith('experimentalAi');
expect(screen.getByTestId('ai')).toBeInTheDocument();
});
it('should work with debugMode flag', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(
<FeatureFlag feature="debugMode">
<div data-testid="debug">Debug Panel</div>
</FeatureFlag>,
);
expect(mockUseFeatureFlag).toHaveBeenCalledWith('debugMode');
expect(screen.getByTestId('debug')).toBeInTheDocument();
});
});
describe('real-world usage patterns', () => {
it('should work for A/B testing pattern', () => {
mockUseFeatureFlag.mockReturnValue(false);
render(
<FeatureFlag feature="newDashboard" fallback={<div data-testid="old-ui">Old UI</div>}>
<div data-testid="new-ui">New UI</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-ui')).not.toBeInTheDocument();
expect(screen.getByTestId('old-ui')).toBeInTheDocument();
});
it('should work for gradual rollout pattern', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(
<div>
<nav data-testid="nav">Navigation</nav>
<FeatureFlag feature="betaRecipes">
<aside data-testid="recipe-suggestions">Recipe Suggestions</aside>
</FeatureFlag>
<main data-testid="main">Main Content</main>
</div>,
);
expect(screen.getByTestId('nav')).toBeInTheDocument();
expect(screen.getByTestId('recipe-suggestions')).toBeInTheDocument();
expect(screen.getByTestId('main')).toBeInTheDocument();
});
it('should work nested within conditional logic', () => {
mockUseFeatureFlag.mockReturnValue(true);
const isLoggedIn = true;
render(
<div>
{isLoggedIn && (
<FeatureFlag
feature="experimentalAi"
fallback={<div data-testid="standard">Standard</div>}
>
<div data-testid="ai-search">AI Search</div>
</FeatureFlag>
)}
</div>,
);
expect(screen.getByTestId('ai-search')).toBeInTheDocument();
});
it('should work with multiple FeatureFlag components', () => {
// First call for newDashboard returns true
// Second call for debugMode returns false
mockUseFeatureFlag.mockImplementation((flag: string) => {
if (flag === 'newDashboard') return true;
if (flag === 'debugMode') return false;
return false;
});
render(
<div>
<FeatureFlag feature="newDashboard">
<div data-testid="new-dashboard">New Dashboard</div>
</FeatureFlag>
<FeatureFlag feature="debugMode" fallback={<div data-testid="no-debug">No Debug</div>}>
<div data-testid="debug-panel">Debug Panel</div>
</FeatureFlag>
</div>,
);
expect(screen.getByTestId('new-dashboard')).toBeInTheDocument();
expect(screen.queryByTestId('debug-panel')).not.toBeInTheDocument();
expect(screen.getByTestId('no-debug')).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined fallback gracefully', () => {
mockUseFeatureFlag.mockReturnValue(false);
const { container } = render(
<FeatureFlag feature="newDashboard" fallback={undefined}>
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
expect(container.innerHTML).toBe('');
});
it('should handle null children gracefully when enabled', () => {
mockUseFeatureFlag.mockReturnValue(true);
const { container } = render(<FeatureFlag feature="newDashboard">{null}</FeatureFlag>);
// Should render nothing (null)
expect(container.innerHTML).toBe('');
});
it('should handle empty children when enabled', () => {
mockUseFeatureFlag.mockReturnValue(true);
const { container } = render(
<FeatureFlag feature="newDashboard">
<></>
</FeatureFlag>,
);
// Should render the empty fragment
expect(container.innerHTML).toBe('');
});
it('should handle boolean children', () => {
mockUseFeatureFlag.mockReturnValue(true);
// React ignores boolean children, so nothing should render
const { container } = render(
<FeatureFlag feature="newDashboard">{true as unknown as React.ReactNode}</FeatureFlag>,
);
expect(container.innerHTML).toBe('');
});
it('should handle number children', () => {
mockUseFeatureFlag.mockReturnValue(true);
render(<FeatureFlag feature="newDashboard">{42}</FeatureFlag>);
expect(screen.getByText('42')).toBeInTheDocument();
});
});
describe('re-rendering behavior', () => {
it('should update when feature flag value changes', () => {
const { rerender } = render(
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
// Initially disabled
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
expect(screen.getByTestId('fallback')).toBeInTheDocument();
// Enable the flag
mockUseFeatureFlag.mockReturnValue(true);
rerender(
<FeatureFlag feature="newDashboard" fallback={<div data-testid="fallback">Fallback</div>}>
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>,
);
// Now enabled
expect(screen.getByTestId('new-feature')).toBeInTheDocument();
expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,75 @@
// src/components/FeatureFlag.tsx
import type { ReactNode } from 'react';
import { useFeatureFlag, type FeatureFlagName } from '../hooks/useFeatureFlag';
/**
* Props for the FeatureFlag component.
*/
export interface FeatureFlagProps {
/**
* The name of the feature flag to check.
* Must be a valid FeatureFlagName defined in config.featureFlags.
*/
feature: FeatureFlagName;
/**
* Content to render when the feature flag is enabled.
*/
children: ReactNode;
/**
* Optional content to render when the feature flag is disabled.
* If not provided, nothing is rendered when the flag is disabled.
* @default null
*/
fallback?: ReactNode;
}
/**
* Declarative component for conditional rendering based on feature flag state.
*
* This component provides a clean, declarative API for rendering content based
* on whether a feature flag is enabled or disabled. It uses the useFeatureFlag
* hook internally and supports an optional fallback for disabled features.
*
* @param props - Component props
* @param props.feature - The feature flag name to check
* @param props.children - Content rendered when feature is enabled
* @param props.fallback - Content rendered when feature is disabled (default: null)
*
* @example
* // Basic usage - show new feature when enabled
* <FeatureFlag feature="newDashboard">
* <NewDashboard />
* </FeatureFlag>
*
* @example
* // With fallback - show alternative when feature is disabled
* <FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
* <NewDashboard />
* </FeatureFlag>
*
* @example
* // Wrap a section of UI that should only appear when flag is enabled
* <div className="sidebar">
* <Navigation />
* <FeatureFlag feature="betaRecipes">
* <RecipeSuggestions />
* </FeatureFlag>
* <Footer />
* </div>
*
* @example
* // Combine with other conditional logic
* {isLoggedIn && (
* <FeatureFlag feature="experimentalAi" fallback={<StandardSearch />}>
* <AiPoweredSearch />
* </FeatureFlag>
* )}
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
export function FeatureFlag({ feature, children, fallback = null }: FeatureFlagProps): ReactNode {
const isEnabled = useFeatureFlag(feature);
return isEnabled ? children : fallback;
}

View File

@@ -24,6 +24,28 @@ const config = {
debug: import.meta.env.VITE_SENTRY_DEBUG === 'true',
enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
},
/**
* Feature flags for conditional feature rendering (ADR-024).
*
* All flags default to false (disabled) when the environment variable is not set
* or is set to any value other than 'true'. This opt-in model ensures features
* are explicitly enabled, preventing accidental exposure of incomplete features.
*
* Environment variables follow the naming convention: VITE_FEATURE_SNAKE_CASE
* Config properties use camelCase for consistency with JavaScript conventions.
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
featureFlags: {
/** Enable the redesigned dashboard UI (VITE_FEATURE_NEW_DASHBOARD) */
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
/** Enable beta recipe features (VITE_FEATURE_BETA_RECIPES) */
betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
/** Enable experimental AI features (VITE_FEATURE_EXPERIMENTAL_AI) */
experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
/** Enable debug mode UI elements (VITE_FEATURE_DEBUG_MODE) */
debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
},
};
export default config;

View File

@@ -155,6 +155,38 @@ const sentrySchema = z.object({
debug: booleanString(false),
});
/**
* Feature flags configuration schema (ADR-024).
*
* All flags default to `false` (disabled) for safety, following an opt-in model.
* Set the corresponding environment variable to 'true' to enable a feature.
*
* Environment variable naming convention: `FEATURE_SNAKE_CASE`
* Config property naming convention: `camelCase`
*
* @example
* // Enable via environment:
* FEATURE_BUGSINK_SYNC=true
*
* // Check in code:
* import { config } from './config/env';
* if (config.featureFlags.bugsinkSync) { ... }
*/
const featureFlagsSchema = z.object({
/** Enable Bugsink error sync integration (FEATURE_BUGSINK_SYNC) */
bugsinkSync: booleanString(false),
/** Enable advanced RBAC features (FEATURE_ADVANCED_RBAC) */
advancedRbac: booleanString(false),
/** Enable new dashboard experience (FEATURE_NEW_DASHBOARD) */
newDashboard: booleanString(false),
/** Enable beta recipe features (FEATURE_BETA_RECIPES) */
betaRecipes: booleanString(false),
/** Enable experimental AI features (FEATURE_EXPERIMENTAL_AI) */
experimentalAi: booleanString(false),
/** Enable debug mode for development (FEATURE_DEBUG_MODE) */
debugMode: booleanString(false),
});
/**
* Complete environment configuration schema.
*/
@@ -170,6 +202,7 @@ const envSchema = z.object({
worker: workerSchema,
server: serverSchema,
sentry: sentrySchema,
featureFlags: featureFlagsSchema,
});
export type EnvConfig = z.infer<typeof envSchema>;
@@ -244,6 +277,14 @@ function loadEnvVars(): unknown {
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
debug: process.env.SENTRY_DEBUG,
},
featureFlags: {
bugsinkSync: process.env.FEATURE_BUGSINK_SYNC,
advancedRbac: process.env.FEATURE_ADVANCED_RBAC,
newDashboard: process.env.FEATURE_NEW_DASHBOARD,
betaRecipes: process.env.FEATURE_BETA_RECIPES,
experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
debugMode: process.env.FEATURE_DEBUG_MODE,
},
};
}
@@ -391,3 +432,33 @@ export const isGoogleOAuthConfigured = !!config.google.clientId && !!config.goog
* Returns true if GitHub OAuth is configured (both client ID and secret present).
*/
export const isGithubOAuthConfigured = !!config.github.clientId && !!config.github.clientSecret;
// --- Feature Flag Helpers (ADR-024) ---
/**
* Type representing valid feature flag names.
* Derived from the featureFlagsSchema for type safety.
*/
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Check if a feature flag is enabled.
*
* This is a convenience function for checking feature flag state.
* For more advanced usage (logging, all flags), use the featureFlags service.
*
* @param flagName - The name of the feature flag to check
* @returns boolean indicating if the feature is enabled
*
* @example
* ```typescript
* import { isFeatureFlagEnabled } from './config/env';
*
* if (isFeatureFlagEnabled('newDashboard')) {
* // Use new dashboard
* }
* ```
*/
export function isFeatureFlagEnabled(flagName: FeatureFlagName): boolean {
return config.featureFlags[flagName];
}

View File

@@ -0,0 +1,300 @@
// src/hooks/useFeatureFlag.test.ts
/**
* Unit tests for the useFeatureFlag React hook (ADR-024).
*
* These tests verify:
* - useFeatureFlag() returns correct boolean for each flag
* - useFeatureFlag() handles all valid flag names
* - useAllFeatureFlags() returns all flag states
* - Default behavior (all flags disabled when not set)
* - Memoization behavior (stable references)
*/
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Define mock feature flags object that will be mutated in tests
// Note: We use a getter function pattern to avoid hoisting issues with vi.mock
vi.mock('../config', () => {
// Create a mutable flags object
const flags = {
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
};
return {
default: {
featureFlags: flags,
},
// Export the flags object for test mutation
__mockFlags: flags,
};
});
// Import config to get access to the mock flags for mutation
import config from '../config';
// Import after mocking
import { useFeatureFlag, useAllFeatureFlags, type FeatureFlagName } from './useFeatureFlag';
// Helper to reset flags
const resetMockFlags = () => {
config.featureFlags.newDashboard = false;
config.featureFlags.betaRecipes = false;
config.featureFlags.experimentalAi = false;
config.featureFlags.debugMode = false;
};
describe('useFeatureFlag hook', () => {
beforeEach(() => {
// Reset mock flags to default state before each test
resetMockFlags();
});
describe('useFeatureFlag()', () => {
it('should return false for disabled flags', () => {
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(false);
});
it('should return true for enabled flags', () => {
config.featureFlags.newDashboard = true;
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(true);
});
it('should return false for betaRecipes when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(result.current).toBe(false);
});
it('should return true for betaRecipes when enabled', () => {
config.featureFlags.betaRecipes = true;
const { result } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(result.current).toBe(true);
});
it('should return false for experimentalAi when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('experimentalAi'));
expect(result.current).toBe(false);
});
it('should return true for experimentalAi when enabled', () => {
config.featureFlags.experimentalAi = true;
const { result } = renderHook(() => useFeatureFlag('experimentalAi'));
expect(result.current).toBe(true);
});
it('should return false for debugMode when disabled', () => {
const { result } = renderHook(() => useFeatureFlag('debugMode'));
expect(result.current).toBe(false);
});
it('should return true for debugMode when enabled', () => {
config.featureFlags.debugMode = true;
const { result } = renderHook(() => useFeatureFlag('debugMode'));
expect(result.current).toBe(true);
});
it('should return consistent value across multiple calls with same flag', () => {
config.featureFlags.newDashboard = true;
const { result: result1 } = renderHook(() => useFeatureFlag('newDashboard'));
const { result: result2 } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result1.current).toBe(result2.current);
expect(result1.current).toBe(true);
});
it('should return different values for different flags', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = false;
const { result: dashboardResult } = renderHook(() => useFeatureFlag('newDashboard'));
const { result: recipesResult } = renderHook(() => useFeatureFlag('betaRecipes'));
expect(dashboardResult.current).toBe(true);
expect(recipesResult.current).toBe(false);
});
it('should memoize the result (stable reference with same flagName)', () => {
const { result, rerender } = renderHook(
({ flagName }: { flagName: FeatureFlagName }) => useFeatureFlag(flagName),
{ initialProps: { flagName: 'newDashboard' as FeatureFlagName } },
);
const firstValue = result.current;
// Rerender with same flag name
rerender({ flagName: 'newDashboard' as FeatureFlagName });
const secondValue = result.current;
// Values should be equal (both false in this case)
expect(firstValue).toBe(secondValue);
});
it('should update when flag name changes', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = false;
const { result, rerender } = renderHook(
({ flagName }: { flagName: FeatureFlagName }) => useFeatureFlag(flagName),
{ initialProps: { flagName: 'newDashboard' as FeatureFlagName } },
);
expect(result.current).toBe(true);
// Change to a different flag
rerender({ flagName: 'betaRecipes' as FeatureFlagName });
expect(result.current).toBe(false);
});
});
describe('useAllFeatureFlags()', () => {
it('should return all flags with their current states', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.debugMode = true;
const { result } = renderHook(() => useAllFeatureFlags());
expect(result.current).toEqual({
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should return all flags as false when none are enabled', () => {
const { result } = renderHook(() => useAllFeatureFlags());
expect(result.current).toEqual({
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
});
});
it('should return a shallow copy (not the original object)', () => {
const { result } = renderHook(() => useAllFeatureFlags());
// Modifying the returned object should not affect the config
const flags = result.current;
(flags as Record<string, boolean>).newDashboard = true;
// Re-render and get fresh flags
const { result: result2 } = renderHook(() => useAllFeatureFlags());
// The mock config should still have the original value
expect(config.featureFlags.newDashboard).toBe(false);
// Note: result2 reads from the mock, which we didn't modify
expect(result2.current.newDashboard).toBe(false);
});
it('should return an object with all expected flag names', () => {
const { result } = renderHook(() => useAllFeatureFlags());
const expectedFlags = ['newDashboard', 'betaRecipes', 'experimentalAi', 'debugMode'];
expect(Object.keys(result.current).sort()).toEqual(expectedFlags.sort());
});
it('should memoize the result', () => {
const { result, rerender } = renderHook(() => useAllFeatureFlags());
const firstValue = result.current;
// Rerender without any changes
rerender();
const secondValue = result.current;
// Should return the same memoized object reference
expect(firstValue).toBe(secondValue);
});
});
describe('FeatureFlagName type', () => {
it('should accept valid flag names at compile time', () => {
// These should compile without TypeScript errors
const validNames: FeatureFlagName[] = [
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
validNames.forEach((name) => {
const { result } = renderHook(() => useFeatureFlag(name));
expect(typeof result.current).toBe('boolean');
});
});
});
});
describe('useFeatureFlag integration scenarios', () => {
beforeEach(() => {
resetMockFlags();
});
it('should work with conditional rendering pattern', () => {
config.featureFlags.newDashboard = true;
const { result } = renderHook(() => {
const isNewDashboard = useFeatureFlag('newDashboard');
return isNewDashboard ? 'new' : 'legacy';
});
expect(result.current).toBe('new');
});
it('should work with multiple flags in same component', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.betaRecipes = true;
config.featureFlags.experimentalAi = false;
const { result } = renderHook(() => ({
dashboard: useFeatureFlag('newDashboard'),
recipes: useFeatureFlag('betaRecipes'),
ai: useFeatureFlag('experimentalAi'),
}));
expect(result.current).toEqual({
dashboard: true,
recipes: true,
ai: false,
});
});
it('should work with useAllFeatureFlags for admin panels', () => {
config.featureFlags.newDashboard = true;
config.featureFlags.debugMode = true;
const { result } = renderHook(() => {
const flags = useAllFeatureFlags();
const enabledCount = Object.values(flags).filter(Boolean).length;
return { flags, enabledCount };
});
expect(result.current.enabledCount).toBe(2);
expect(result.current.flags.newDashboard).toBe(true);
expect(result.current.flags.debugMode).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
// src/hooks/useFeatureFlag.ts
import { useMemo } from 'react';
import config from '../config';
/**
* Union type of all available feature flag names.
* This type is derived from the config.featureFlags object to ensure
* type safety and autocomplete support when checking feature flags.
*
* @example
* const flagName: FeatureFlagName = 'newDashboard'; // Valid
* const invalid: FeatureFlagName = 'nonexistent'; // TypeScript error
*/
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* React hook to check if a feature flag is enabled.
*
* Feature flags are loaded from environment variables at build time and
* cannot change during runtime. This hook memoizes the result to prevent
* unnecessary re-renders when the component re-renders.
*
* @param flagName - The name of the feature flag to check (must be a valid FeatureFlagName)
* @returns boolean indicating if the feature is enabled (true) or disabled (false)
*
* @example
* // Basic usage - conditionally render UI
* function Dashboard() {
* const isNewDashboard = useFeatureFlag('newDashboard');
*
* if (isNewDashboard) {
* return <NewDashboard />;
* }
* return <LegacyDashboard />;
* }
*
* @example
* // Track feature flag usage with analytics
* function FeatureComponent() {
* const isExperimentalAi = useFeatureFlag('experimentalAi');
*
* useEffect(() => {
* if (isExperimentalAi) {
* analytics.track('experimental_ai_enabled');
* }
* }, [isExperimentalAi]);
*
* return isExperimentalAi ? <AiFeature /> : null;
* }
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
return useMemo(() => config.featureFlags[flagName], [flagName]);
}
/**
* React hook to get all feature flags and their current states.
*
* This hook is useful for debugging, admin panels, or components that
* need to display the current feature flag configuration. The returned
* object is a shallow copy to prevent accidental mutation of the config.
*
* @returns Record mapping each feature flag name to its boolean state
*
* @example
* // Display feature flag status in an admin panel
* function FeatureFlagDebugPanel() {
* const flags = useAllFeatureFlags();
*
* return (
* <ul>
* {Object.entries(flags).map(([name, enabled]) => (
* <li key={name}>
* {name}: {enabled ? 'Enabled' : 'Disabled'}
* </li>
* ))}
* </ul>
* );
* }
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return useMemo(() => ({ ...config.featureFlags }), []);
}

View File

@@ -17,7 +17,6 @@ vi.mock('driver.js', () => ({
DriveStep: vi.fn(),
}));
// @ts-expect-error - driver.js types are not fully compatible, but the mock works
import { driver } from 'driver.js';
const mockDriver = driver as Mock;

View File

@@ -108,6 +108,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),
@@ -212,7 +220,9 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/approve should approve a correction', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/approve`,
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ message: 'Correction approved successfully.' });
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
@@ -224,14 +234,18 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/approve`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/approve`,
);
expect(response.status).toBe(500);
});
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/reject`,
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ message: 'Correction rejected successfully.' });
});
@@ -239,7 +253,9 @@ describe('Admin Content Management Routes (/api/v1/admin)', () => {
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/v1/admin/corrections/${correctionId}/reject`);
const response = await supertest(app).post(
`/api/v1/admin/corrections/${correctionId}/reject`,
);
expect(response.status).toBe(500);
});

View File

@@ -74,9 +74,41 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),
isDevelopment: false,
}));
// Mock the feature flags service
vi.mock('../services/featureFlags.server', () => ({
getFeatureFlags: vi.fn(() => ({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
})),
isFeatureEnabled: vi.fn((flag: string) => {
const flags: Record<string, boolean> = {
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
};
return flags[flag] ?? false;
}),
}));
// Mock Passport to allow admin access
@@ -93,6 +125,7 @@ vi.mock('../config/passport', () => ({
import adminRouter from './admin.routes';
import { cacheService } from '../services/cacheService.server';
import { getFeatureFlags } from '../services/featureFlags.server';
import { mockLogger } from '../tests/utils/mockLogger';
describe('Admin Routes Rate Limiting', () => {
@@ -177,4 +210,67 @@ describe('Admin Routes Rate Limiting', () => {
);
});
});
describe('GET /feature-flags (ADR-024)', () => {
it('should return 200 and the current feature flag states', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.flags).toEqual({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should call getFeatureFlags service function', async () => {
await supertest(app).get('/api/v1/admin/feature-flags');
expect(vi.mocked(getFeatureFlags)).toHaveBeenCalled();
});
it('should return flags with all expected keys', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
const expectedFlags = [
'bugsinkSync',
'advancedRbac',
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
expect(response.status).toBe(200);
expect(Object.keys(response.body.data.flags).sort()).toEqual(expectedFlags.sort());
});
it('should return boolean values for all flags', async () => {
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(200);
Object.values(response.body.data.flags).forEach((value) => {
expect(typeof value).toBe('boolean');
});
});
it('should return 500 if getFeatureFlags throws an error', async () => {
const featureFlagsError = new Error('Feature flags service error');
vi.mocked(getFeatureFlags).mockImplementationOnce(() => {
throw featureFlagsError;
});
const response = await supertest(app).get('/api/v1/admin/feature-flags');
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: featureFlagsError },
'Error fetching feature flags',
);
});
});
});

View File

@@ -33,6 +33,7 @@ import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
import { getFeatureFlags } from '../services/featureFlags.server';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -1229,6 +1230,59 @@ router.get(
},
);
/**
* @openapi
* /admin/feature-flags:
* get:
* tags: [Admin]
* summary: Get feature flags status
* description: Get the current state of all feature flags. Requires admin role. (ADR-024)
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Feature flags and their current states
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* flags:
* type: object
* additionalProperties:
* type: boolean
* example:
* bugsinkSync: false
* advancedRbac: false
* newDashboard: true
* betaRecipes: false
* experimentalAi: false
* debugMode: false
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.get(
'/feature-flags',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const flags = getFeatureFlags();
sendSuccess(res, { flags });
} catch (error) {
req.log.error({ error }, 'Error fetching feature flags');
next(error);
}
},
);
/**
* @openapi
* /admin/websocket/stats:

View File

@@ -40,6 +40,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -45,6 +45,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -47,6 +47,14 @@ vi.mock('../config/env', () => ({
redis: { url: 'redis://localhost:6379' },
auth: { jwtSecret: 'test-secret' },
server: { port: 3000, host: 'localhost' },
featureFlags: {
bugsinkSync: false,
advancedRbac: false,
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
isAiConfigured: vi.fn().mockReturnValue(false),
parseConfig: vi.fn(),

View File

@@ -0,0 +1,465 @@
// src/services/featureFlags.server.test.ts
/**
* Unit tests for the Feature Flags Service (ADR-024).
*
* These tests verify:
* - isFeatureEnabled() returns correct boolean for each flag
* - isFeatureEnabled() handles all valid flag names
* - getFeatureFlags() returns all flags and their states
* - getEnabledFeatureFlags() returns only enabled flags
* - Convenience exports return correct values
* - Default behavior (all flags disabled when not set)
* - Environment variable parsing for enabled/disabled states
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Store original process.env
const originalEnv = { ...process.env };
describe('featureFlags.server', () => {
beforeEach(() => {
// Reset modules before each test to allow re-importing with different env vars
vi.resetModules();
// Reset process.env to original state
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original process.env
process.env = originalEnv;
});
/**
* Helper to set up the minimum required environment variables for config to load.
* This includes database, redis, and auth config that are required by Zod validation.
*/
const setMinimalValidEnv = (overrides: Record<string, string> = {}) => {
process.env = {
...process.env,
// Required config
NODE_ENV: 'test',
DB_HOST: 'localhost',
DB_USER: 'test',
DB_PASSWORD: 'test',
DB_NAME: 'test',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long',
// Feature flags default to false, so we override as needed
...overrides,
};
};
describe('isFeatureEnabled()', () => {
it('should return false for all flags when no feature flags are set', async () => {
setMinimalValidEnv();
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('bugsinkSync')).toBe(false);
expect(isFeatureEnabled('advancedRbac')).toBe(false);
expect(isFeatureEnabled('newDashboard')).toBe(false);
expect(isFeatureEnabled('betaRecipes')).toBe(false);
expect(isFeatureEnabled('experimentalAi')).toBe(false);
expect(isFeatureEnabled('debugMode')).toBe(false);
});
it('should return true for bugsinkSync when FEATURE_BUGSINK_SYNC is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_BUGSINK_SYNC: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('bugsinkSync')).toBe(true);
});
it('should return true for advancedRbac when FEATURE_ADVANCED_RBAC is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_ADVANCED_RBAC: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('advancedRbac')).toBe(true);
});
it('should return true for newDashboard when FEATURE_NEW_DASHBOARD is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(true);
});
it('should return true for betaRecipes when FEATURE_BETA_RECIPES is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_BETA_RECIPES: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('betaRecipes')).toBe(true);
});
it('should return true for experimentalAi when FEATURE_EXPERIMENTAL_AI is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_EXPERIMENTAL_AI: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('experimentalAi')).toBe(true);
});
it('should return true for debugMode when FEATURE_DEBUG_MODE is set to "true"', async () => {
setMinimalValidEnv({ FEATURE_DEBUG_MODE: 'true' });
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('debugMode')).toBe(true);
});
it('should return false when flag is set to "false"', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'false',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return false for non-"true" string values', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'TRUE', // uppercase
});
const { isFeatureEnabled } = await import('./featureFlags.server');
// The booleanString helper only checks for exact 'true' match
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return false for empty string value', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: '',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should handle multiple flags enabled simultaneously', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_BETA_RECIPES: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { isFeatureEnabled } = await import('./featureFlags.server');
expect(isFeatureEnabled('newDashboard')).toBe(true);
expect(isFeatureEnabled('betaRecipes')).toBe(true);
expect(isFeatureEnabled('debugMode')).toBe(true);
// These should still be false
expect(isFeatureEnabled('bugsinkSync')).toBe(false);
expect(isFeatureEnabled('advancedRbac')).toBe(false);
expect(isFeatureEnabled('experimentalAi')).toBe(false);
});
});
describe('getFeatureFlags()', () => {
it('should return all flags with their current states', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
expect(flags).toEqual({
bugsinkSync: false,
advancedRbac: false,
newDashboard: true,
betaRecipes: false,
experimentalAi: false,
debugMode: true,
});
});
it('should return a copy of flags (not the original object)', async () => {
setMinimalValidEnv();
const { getFeatureFlags, isFeatureEnabled } = await import('./featureFlags.server');
const flags = getFeatureFlags();
// Modifying the returned object should not affect the original
(flags as Record<string, boolean>).newDashboard = true;
// The original should still be false
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
it('should return all flags as false when no flags are set', async () => {
setMinimalValidEnv();
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
// All values should be false
Object.values(flags).forEach((value) => {
expect(value).toBe(false);
});
});
it('should include all expected flag names', async () => {
setMinimalValidEnv();
const { getFeatureFlags } = await import('./featureFlags.server');
const flags = getFeatureFlags();
const expectedFlags = [
'bugsinkSync',
'advancedRbac',
'newDashboard',
'betaRecipes',
'experimentalAi',
'debugMode',
];
expect(Object.keys(flags).sort()).toEqual(expectedFlags.sort());
});
});
describe('getEnabledFeatureFlags()', () => {
it('should return an empty array when no flags are enabled', async () => {
setMinimalValidEnv();
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toEqual([]);
});
it('should return only enabled flag names', async () => {
setMinimalValidEnv({
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toHaveLength(2);
expect(enabledFlags).toContain('newDashboard');
expect(enabledFlags).toContain('debugMode');
expect(enabledFlags).not.toContain('bugsinkSync');
expect(enabledFlags).not.toContain('advancedRbac');
});
it('should return all flag names when all flags are enabled', async () => {
setMinimalValidEnv({
FEATURE_BUGSINK_SYNC: 'true',
FEATURE_ADVANCED_RBAC: 'true',
FEATURE_NEW_DASHBOARD: 'true',
FEATURE_BETA_RECIPES: 'true',
FEATURE_EXPERIMENTAL_AI: 'true',
FEATURE_DEBUG_MODE: 'true',
});
const { getEnabledFeatureFlags } = await import('./featureFlags.server');
const enabledFlags = getEnabledFeatureFlags();
expect(enabledFlags).toHaveLength(6);
expect(enabledFlags).toContain('bugsinkSync');
expect(enabledFlags).toContain('advancedRbac');
expect(enabledFlags).toContain('newDashboard');
expect(enabledFlags).toContain('betaRecipes');
expect(enabledFlags).toContain('experimentalAi');
expect(enabledFlags).toContain('debugMode');
});
});
describe('convenience exports', () => {
it('should export isBugsinkSyncEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isBugsinkSyncEnabled } = await import('./featureFlags.server');
expect(isBugsinkSyncEnabled).toBe(false);
});
it('should export isBugsinkSyncEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_BUGSINK_SYNC: 'true' });
const { isBugsinkSyncEnabled } = await import('./featureFlags.server');
expect(isBugsinkSyncEnabled).toBe(true);
});
it('should export isAdvancedRbacEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isAdvancedRbacEnabled } = await import('./featureFlags.server');
expect(isAdvancedRbacEnabled).toBe(false);
});
it('should export isAdvancedRbacEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_ADVANCED_RBAC: 'true' });
const { isAdvancedRbacEnabled } = await import('./featureFlags.server');
expect(isAdvancedRbacEnabled).toBe(true);
});
it('should export isNewDashboardEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isNewDashboardEnabled } = await import('./featureFlags.server');
expect(isNewDashboardEnabled).toBe(false);
});
it('should export isNewDashboardEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isNewDashboardEnabled } = await import('./featureFlags.server');
expect(isNewDashboardEnabled).toBe(true);
});
it('should export isBetaRecipesEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isBetaRecipesEnabled } = await import('./featureFlags.server');
expect(isBetaRecipesEnabled).toBe(false);
});
it('should export isBetaRecipesEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_BETA_RECIPES: 'true' });
const { isBetaRecipesEnabled } = await import('./featureFlags.server');
expect(isBetaRecipesEnabled).toBe(true);
});
it('should export isExperimentalAiEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isExperimentalAiEnabled } = await import('./featureFlags.server');
expect(isExperimentalAiEnabled).toBe(false);
});
it('should export isExperimentalAiEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_EXPERIMENTAL_AI: 'true' });
const { isExperimentalAiEnabled } = await import('./featureFlags.server');
expect(isExperimentalAiEnabled).toBe(true);
});
it('should export isDebugModeEnabled as false when flag is not set', async () => {
setMinimalValidEnv();
const { isDebugModeEnabled } = await import('./featureFlags.server');
expect(isDebugModeEnabled).toBe(false);
});
it('should export isDebugModeEnabled as true when flag is set', async () => {
setMinimalValidEnv({ FEATURE_DEBUG_MODE: 'true' });
const { isDebugModeEnabled } = await import('./featureFlags.server');
expect(isDebugModeEnabled).toBe(true);
});
});
describe('FeatureFlagName type', () => {
it('should re-export the FeatureFlagName type from env.ts', async () => {
setMinimalValidEnv();
const featureFlagsModule = await import('./featureFlags.server');
// TypeScript will enforce that FeatureFlagName is properly exported
// This test verifies the export exists at runtime
expect(featureFlagsModule).toHaveProperty('isFeatureEnabled');
// The type export is verified by TypeScript compilation
// This runtime test ensures the module loads correctly
});
});
describe('development mode logging', () => {
it('should log feature flag checks in development mode', async () => {
setMinimalValidEnv();
// Override NODE_ENV to development for this test
process.env.NODE_ENV = 'development';
// Mock the logger
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
};
vi.doMock('./logger.server', () => ({
logger: mockLogger,
}));
const { isFeatureEnabled } = await import('./featureFlags.server');
isFeatureEnabled('newDashboard');
// In development mode, the logger.debug should be called
expect(mockLogger.debug).toHaveBeenCalledWith(
{ flag: 'newDashboard', enabled: false },
'Feature flag checked',
);
});
it('should not log in test mode', async () => {
setMinimalValidEnv();
// Mock the logger
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
};
vi.doMock('./logger.server', () => ({
logger: mockLogger,
}));
const { isFeatureEnabled } = await import('./featureFlags.server');
isFeatureEnabled('newDashboard');
// In test mode (NODE_ENV=test), the logger.debug should not be called
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});
});
describe('isFeatureFlagEnabled in env.ts', () => {
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
const setMinimalValidEnv = (overrides: Record<string, string> = {}) => {
process.env = {
...process.env,
NODE_ENV: 'test',
DB_HOST: 'localhost',
DB_USER: 'test',
DB_PASSWORD: 'test',
DB_NAME: 'test',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long',
...overrides,
};
};
it('should return correct value from isFeatureFlagEnabled in env.ts', async () => {
setMinimalValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { isFeatureFlagEnabled } = await import('../config/env');
expect(isFeatureFlagEnabled('newDashboard')).toBe(true);
expect(isFeatureFlagEnabled('betaRecipes')).toBe(false);
});
it('should default to false for undefined flags', async () => {
setMinimalValidEnv();
const { isFeatureFlagEnabled } = await import('../config/env');
expect(isFeatureFlagEnabled('bugsinkSync')).toBe(false);
expect(isFeatureFlagEnabled('advancedRbac')).toBe(false);
expect(isFeatureFlagEnabled('newDashboard')).toBe(false);
expect(isFeatureFlagEnabled('betaRecipes')).toBe(false);
expect(isFeatureFlagEnabled('experimentalAi')).toBe(false);
expect(isFeatureFlagEnabled('debugMode')).toBe(false);
});
});

View File

@@ -0,0 +1,169 @@
// src/services/featureFlags.server.ts
/**
* Feature Flags Service (ADR-024)
*
* This module provides a centralized service for accessing feature flags
* on the backend. It integrates with the Zod-validated configuration in
* `src/config/env.ts` and provides type-safe access patterns.
*
* All feature flags default to `false` (disabled) following an opt-in model.
* Set the corresponding `FEATURE_*` environment variable to 'true' to enable.
*
* @example
* ```typescript
* import { isFeatureEnabled, getFeatureFlags } from './services/featureFlags.server';
*
* // Check a specific flag
* if (isFeatureEnabled('newDashboard')) {
* // Use new dashboard logic
* }
*
* // Get all flags (for admin endpoints)
* const allFlags = getFeatureFlags();
* ```
*
* @see docs/adr/0024-feature-flagging-strategy.md for architecture details
*/
import { config, isDevelopment, FeatureFlagName } from '../config/env';
import { logger } from './logger.server';
// Re-export FeatureFlagName for convenience
export type { FeatureFlagName };
/**
* Check if a feature flag is enabled.
*
* In development mode, this function logs the flag check for debugging purposes.
* In production/test, logging is omitted to avoid performance overhead.
*
* @param flagName - The name of the feature flag to check (type-safe)
* @returns boolean indicating if the feature is enabled
*
* @example
* ```typescript
* import { isFeatureEnabled } from '../services/featureFlags.server';
*
* // In a route handler
* router.get('/dashboard', async (req, res) => {
* if (isFeatureEnabled('newDashboard')) {
* return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
* }
* return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
* });
*
* // In a service
* function processFlyer(flyer: Flyer): ProcessedFlyer {
* if (isFeatureEnabled('experimentalAi')) {
* return processWithExperimentalAi(flyer);
* }
* return processWithStandardAi(flyer);
* }
* ```
*/
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
const enabled = config.featureFlags[flagName];
if (isDevelopment) {
logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
}
return enabled;
}
/**
* Get all feature flags and their current states.
*
* This function returns a shallow copy of all feature flags,
* useful for admin/debug endpoints and monitoring dashboards.
*
* @returns Record of all feature flag names to their boolean states
*
* @example
* ```typescript
* import { getFeatureFlags } from '../services/featureFlags.server';
*
* // In an admin route handler
* router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
* const flags = getFeatureFlags();
* sendSuccess(res, { flags });
* });
*
* // Result:
* // {
* // "bugsinkSync": false,
* // "advancedRbac": false,
* // "newDashboard": true,
* // "betaRecipes": false,
* // "experimentalAi": false,
* // "debugMode": false
* // }
* ```
*/
export function getFeatureFlags(): Record<FeatureFlagName, boolean> {
// Return a shallow copy to prevent external mutation
return { ...config.featureFlags };
}
/**
* Get a list of all enabled feature flags.
*
* Useful for logging and diagnostics to quickly see which features are active.
*
* @returns Array of feature flag names that are currently enabled
*
* @example
* ```typescript
* import { getEnabledFeatureFlags } from '../services/featureFlags.server';
*
* // Log enabled features at startup
* const enabled = getEnabledFeatureFlags();
* if (enabled.length > 0) {
* logger.info({ enabledFlags: enabled }, 'Active feature flags');
* }
* ```
*/
export function getEnabledFeatureFlags(): FeatureFlagName[] {
const flags = config.featureFlags;
return (Object.keys(flags) as FeatureFlagName[]).filter((key) => flags[key]);
}
// --- Convenience Exports ---
// These are evaluated once at module load time (startup).
// Use these for simple boolean checks when you don't need dynamic behavior.
/**
* True if Bugsink error sync integration is enabled.
* @see FEATURE_BUGSINK_SYNC environment variable
*/
export const isBugsinkSyncEnabled = config.featureFlags.bugsinkSync;
/**
* True if advanced RBAC features are enabled.
* @see FEATURE_ADVANCED_RBAC environment variable
*/
export const isAdvancedRbacEnabled = config.featureFlags.advancedRbac;
/**
* True if new dashboard experience is enabled.
* @see FEATURE_NEW_DASHBOARD environment variable
*/
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
/**
* True if beta recipe features are enabled.
* @see FEATURE_BETA_RECIPES environment variable
*/
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
/**
* True if experimental AI features are enabled.
* @see FEATURE_EXPERIMENTAL_AI environment variable
*/
export const isExperimentalAiEnabled = config.featureFlags.experimentalAi;
/**
* True if debug mode is enabled.
* @see FEATURE_DEBUG_MODE environment variable
*/
export const isDebugModeEnabled = config.featureFlags.debugMode;

26
src/vite-env.d.ts vendored
View File

@@ -5,7 +5,31 @@ interface ImportMetaEnv {
readonly VITE_APP_COMMIT_MESSAGE: string;
readonly VITE_APP_COMMIT_URL: string;
readonly VITE_GOOGLE_MAPS_EMBED_API_KEY: string;
// Add any other environment variables you use here
// Sentry/Bugsink Configuration (ADR-015)
readonly VITE_SENTRY_DSN?: string;
readonly VITE_SENTRY_ENVIRONMENT?: string;
readonly VITE_SENTRY_DEBUG?: string;
readonly VITE_SENTRY_ENABLED?: string;
/**
* Feature Flags (ADR-024)
*
* All feature flag environment variables are optional and default to disabled
* when not set. Set to 'true' to enable a feature.
*
* Naming convention: VITE_FEATURE_SNAKE_CASE
*
* @see docs/adr/0024-feature-flagging-strategy.md
*/
/** Enable the redesigned dashboard UI */
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
/** Enable beta recipe features */
readonly VITE_FEATURE_BETA_RECIPES?: string;
/** Enable experimental AI features */
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
/** Enable debug mode UI elements */
readonly VITE_FEATURE_DEBUG_MODE?: string;
}
interface ImportMeta {