Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
572
docs/architecture/FEATURE-FLAGS.md
Normal file
572
docs/architecture/FEATURE-FLAGS.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# Feature Flag System
|
||||
|
||||
> ArchiTools internal architecture reference -- feature flag design, implementation, and usage patterns.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The feature flag system controls which modules and capabilities are available at runtime. It serves three purposes:
|
||||
|
||||
1. **Module gating** -- a module whose flag is disabled is never imported, never bundled into the active page, and never visible in navigation.
|
||||
2. **Incremental rollout** -- experimental features can be enabled for specific users or environments without a separate deployment.
|
||||
3. **Operational control** -- system flags allow disabling capabilities (e.g., AI features) without a code change.
|
||||
|
||||
Flags are defined statically in `src/config/flags.ts`, consumed via a React context provider, and checked with a hook or a gate component. There is no remote flag service today; the system is designed so one can be added without changing consumer code.
|
||||
|
||||
---
|
||||
|
||||
## Flag Definition Model
|
||||
|
||||
```typescript
|
||||
// src/types/flags.ts
|
||||
|
||||
type FlagCategory = 'module' | 'experimental' | 'system';
|
||||
|
||||
interface FeatureFlag {
|
||||
key: string; // unique identifier, matches module's featureFlag field
|
||||
enabled: boolean; // default state
|
||||
label: string; // Romanian display name
|
||||
description: string; // Romanian description of what this flag controls
|
||||
category: FlagCategory; // classification for the admin UI
|
||||
requiredRole?: Role; // minimum role to see this feature (future use)
|
||||
dependencies?: string[]; // other flag keys that must be enabled for this flag to take effect
|
||||
overridable: boolean; // whether this flag can be toggled at runtime via admin panel
|
||||
}
|
||||
```
|
||||
|
||||
### Field Details
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `key` | Convention: `module.[module-id]` for module flags, `exp.[feature]` for experimental, `sys.[capability]` for system. |
|
||||
| `enabled` | The compiled default. This is what ships. Can be overridden per-environment via env vars or at runtime. |
|
||||
| `dependencies` | If flag A depends on flag B, enabling A while B is disabled has no effect -- the system treats A as disabled. |
|
||||
| `overridable` | System-critical flags (e.g., `sys.storage-engine`) should be `false` to prevent accidental runtime changes. |
|
||||
| `requiredRole` | Not enforced today. Reserved for role-based flag filtering when the auth system is implemented. |
|
||||
|
||||
---
|
||||
|
||||
## Default Flags Configuration
|
||||
|
||||
All flags are declared in a single file. This is the source of truth.
|
||||
|
||||
```typescript
|
||||
// src/config/flags.ts
|
||||
|
||||
import type { FeatureFlag } from '@/types/flags';
|
||||
|
||||
export const defaultFlags: FeatureFlag[] = [
|
||||
// ─── Module Flags ──────────────────────────────────────────
|
||||
{
|
||||
key: 'module.devize-generator',
|
||||
enabled: true,
|
||||
label: 'Generator Devize',
|
||||
description: 'Activeaza modulul de generare devize.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'module.oferte',
|
||||
enabled: true,
|
||||
label: 'Oferte',
|
||||
description: 'Activeaza modulul de creare si gestionare oferte.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'module.pontaj',
|
||||
enabled: true,
|
||||
label: 'Pontaj',
|
||||
description: 'Activeaza modulul de pontaj si evidenta ore.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
|
||||
// ─── Experimental Flags ────────────────────────────────────
|
||||
{
|
||||
key: 'exp.ai-assistant',
|
||||
enabled: false,
|
||||
label: 'Asistent AI',
|
||||
description: 'Activeaza asistentul AI experimental pentru generare de continut.',
|
||||
category: 'experimental',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'exp.dark-mode',
|
||||
enabled: false,
|
||||
label: 'Mod Intunecat',
|
||||
description: 'Activeaza tema intunecata (in testare).',
|
||||
category: 'experimental',
|
||||
overridable: true,
|
||||
},
|
||||
|
||||
// ─── System Flags ─────────────────────────────────────────
|
||||
{
|
||||
key: 'sys.offline-mode',
|
||||
enabled: true,
|
||||
label: 'Mod Offline',
|
||||
description: 'Permite functionarea aplicatiei fara conexiune la internet.',
|
||||
category: 'system',
|
||||
overridable: false,
|
||||
},
|
||||
{
|
||||
key: 'sys.analytics',
|
||||
enabled: false,
|
||||
label: 'Analitice',
|
||||
description: 'Colectare de date de utilizare anonimizate.',
|
||||
category: 'system',
|
||||
overridable: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** Build a lookup map for O(1) access by key */
|
||||
export const flagDefaults: Map<string, FeatureFlag> = new Map(
|
||||
defaultFlags.map((flag) => [flag.key, flag])
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FeatureFlagProvider
|
||||
|
||||
The provider initializes flag state from defaults, applies environment variable overrides, applies runtime overrides from localStorage, resolves dependencies, and exposes the resolved state via context.
|
||||
|
||||
```typescript
|
||||
// src/providers/FeatureFlagProvider.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||
import { defaultFlags, flagDefaults } from '@/config/flags';
|
||||
import type { FeatureFlag } from '@/types/flags';
|
||||
|
||||
interface FlagState {
|
||||
/** Check if a flag is enabled (dependency-resolved) */
|
||||
isEnabled: (key: string) => boolean;
|
||||
/** Get the full flag definition */
|
||||
getFlag: (key: string) => FeatureFlag | undefined;
|
||||
/** Set a runtime override (only for overridable flags) */
|
||||
setOverride: (key: string, enabled: boolean) => void;
|
||||
/** Clear a runtime override */
|
||||
clearOverride: (key: string) => void;
|
||||
/** All flags with their resolved states */
|
||||
allFlags: FeatureFlag[];
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FlagState | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'architools.flag-overrides';
|
||||
|
||||
function loadOverrides(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOverrides(overrides: Record<string, boolean>): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
|
||||
}
|
||||
|
||||
function getEnvOverride(key: string): boolean | undefined {
|
||||
// Convention: NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR=true
|
||||
// Transform flag key: "module.devize-generator" -> "MODULE_DEVIZE_GENERATOR"
|
||||
const envKey = `NEXT_PUBLIC_FLAG_${key.replace(/[.-]/g, '_').toUpperCase()}`;
|
||||
|
||||
const value = process.env[envKey];
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
|
||||
const [overrides, setOverrides] = useState<Record<string, boolean>>(loadOverrides);
|
||||
|
||||
const resolvedFlags = useMemo(() => {
|
||||
// Step 1: Compute effective enabled state for each flag
|
||||
const effective = new Map<string, boolean>();
|
||||
|
||||
for (const flag of defaultFlags) {
|
||||
// Priority: env override > runtime override > compiled default
|
||||
const envOverride = getEnvOverride(flag.key);
|
||||
const runtimeOverride = overrides[flag.key];
|
||||
const baseValue = envOverride ?? runtimeOverride ?? flag.enabled;
|
||||
effective.set(flag.key, baseValue);
|
||||
}
|
||||
|
||||
// Step 2: Resolve dependencies (a flag is only enabled if all its dependencies are enabled)
|
||||
const resolved = new Map<string, boolean>();
|
||||
|
||||
function resolve(key: string, visited: Set<string> = new Set()): boolean {
|
||||
if (resolved.has(key)) return resolved.get(key)!;
|
||||
if (visited.has(key)) return false; // circular dependency -- fail closed
|
||||
visited.add(key);
|
||||
|
||||
const flag = flagDefaults.get(key);
|
||||
if (!flag) return false;
|
||||
|
||||
let enabled = effective.get(key) ?? false;
|
||||
|
||||
if (enabled && flag.dependencies?.length) {
|
||||
enabled = flag.dependencies.every((dep) => resolve(dep, visited));
|
||||
}
|
||||
|
||||
resolved.set(key, enabled);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
for (const flag of defaultFlags) {
|
||||
resolve(flag.key);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}, [overrides]);
|
||||
|
||||
const setOverride = useCallback((key: string, enabled: boolean) => {
|
||||
const flag = flagDefaults.get(key);
|
||||
if (!flag?.overridable) {
|
||||
console.warn(`[FeatureFlags] Flag "${key}" is not overridable.`);
|
||||
return;
|
||||
}
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev, [key]: enabled };
|
||||
saveOverrides(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearOverride = useCallback((key: string) => {
|
||||
setOverrides((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
saveOverrides(rest);
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<FlagState>(() => ({
|
||||
isEnabled: (key: string) => resolvedFlags.get(key) ?? false,
|
||||
getFlag: (key: string) => flagDefaults.get(key),
|
||||
setOverride,
|
||||
clearOverride,
|
||||
allFlags: defaultFlags.map((f) => ({
|
||||
...f,
|
||||
enabled: resolvedFlags.get(f.key) ?? false,
|
||||
})),
|
||||
}), [resolvedFlags, setOverride, clearOverride]);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFeatureFlagContext(): FlagState {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlagContext must be used within a FeatureFlagProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Placement
|
||||
|
||||
The provider wraps the entire application layout:
|
||||
|
||||
```typescript
|
||||
// src/app/layout.tsx
|
||||
|
||||
import { FeatureFlagProvider } from '@/providers/FeatureFlagProvider';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ro">
|
||||
<body>
|
||||
<FeatureFlagProvider>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `useFeatureFlag` Hook
|
||||
|
||||
The primary consumer API. Returns a boolean.
|
||||
|
||||
```typescript
|
||||
// src/hooks/useFeatureFlag.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlagContext } from '@/providers/FeatureFlagProvider';
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
* Returns false for unknown keys (fail closed).
|
||||
*/
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { isEnabled } = useFeatureFlagContext();
|
||||
return isEnabled(key);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
function SomeComponent() {
|
||||
const aiEnabled = useFeatureFlag('exp.ai-assistant');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{aiEnabled && <AiSuggestionPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `FeatureGate` Component
|
||||
|
||||
A declarative alternative to the hook, useful when you want to conditionally render a subtree without introducing a new component.
|
||||
|
||||
```typescript
|
||||
// src/components/FeatureGate.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
|
||||
interface FeatureGateProps {
|
||||
flag: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders children only if the specified flag is enabled.
|
||||
* Optionally renders a fallback when disabled.
|
||||
*/
|
||||
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
|
||||
const enabled = useFeatureFlag(flag);
|
||||
return <>{enabled ? children : fallback}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
<FeatureGate flag="exp.dark-mode">
|
||||
<ThemeToggle />
|
||||
</FeatureGate>
|
||||
|
||||
<FeatureGate flag="module.pontaj" fallback={<ModuleDisabledNotice />}>
|
||||
<PontajWidget />
|
||||
</FeatureGate>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flags and Module Loading
|
||||
|
||||
The critical integration point: **a disabled flag means the module's JavaScript is never fetched**.
|
||||
|
||||
This works because of the layered architecture:
|
||||
|
||||
1. **Navigation**: The sidebar queries `useFeatureFlag(module.featureFlag)` for each registered module. Disabled modules are excluded from the nav -- the user has no link to click.
|
||||
|
||||
2. **Route guard**: The module's route page checks the flag before rendering the `ModuleLoader`. If the flag is disabled, it renders a 404 or redirect.
|
||||
|
||||
```typescript
|
||||
// src/app/(modules)/devize/page.tsx
|
||||
|
||||
import { ModuleLoader } from '@/lib/module-loader';
|
||||
import { moduleRegistry } from '@/config/modules';
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function DevizePage() {
|
||||
const config = moduleRegistry.get('devize-generator')!;
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
|
||||
if (!enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Lazy import**: `React.lazy(() => import(...))` only executes the import when the component renders. Since the route guard prevents rendering when the flag is off, the dynamic `import()` never fires, and the browser never requests the chunk.
|
||||
|
||||
**Result**: Disabling a module flag removes it from the bundle that the user downloads. There is zero performance cost for disabled modules.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Override Mechanism
|
||||
|
||||
Overridable flags can be toggled at runtime through the admin panel or the browser console. Overrides are stored in `localStorage` under the key `architools.flag-overrides`.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
The admin panel (planned) will render all overridable flags as toggles, grouped by category. It uses `setOverride` and `clearOverride` from the context.
|
||||
|
||||
### Console API (Development)
|
||||
|
||||
For development and debugging, flags can be manipulated directly:
|
||||
|
||||
```javascript
|
||||
// In the browser console:
|
||||
|
||||
// Enable a flag
|
||||
localStorage.setItem('architools.flag-overrides',
|
||||
JSON.stringify({ ...JSON.parse(localStorage.getItem('architools.flag-overrides') || '{}'), 'exp.ai-assistant': true })
|
||||
);
|
||||
// Then reload the page.
|
||||
```
|
||||
|
||||
A convenience utility can be exposed on `window` in development builds:
|
||||
|
||||
```typescript
|
||||
// src/lib/dev-tools.ts
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(window as any).__flags = {
|
||||
enable: (key: string) => { /* calls setOverride */ },
|
||||
disable: (key: string) => { /* calls setOverride */ },
|
||||
reset: (key: string) => { /* calls clearOverride */ },
|
||||
list: () => { /* logs all flags with their resolved states */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag Categories
|
||||
|
||||
### Module Flags (`module.*`)
|
||||
|
||||
One flag per module. Controls whether the module is loaded and visible. Key must match the `featureFlag` field in the module's `ModuleConfig`.
|
||||
|
||||
Examples: `module.devize-generator`, `module.oferte`, `module.pontaj`.
|
||||
|
||||
### Experimental Flags (`exp.*`)
|
||||
|
||||
Control features that are under active development or testing. These may cut across modules or affect platform-level behavior. Expected to eventually become permanent (either promoted to always-on or removed).
|
||||
|
||||
Examples: `exp.ai-assistant`, `exp.dark-mode`.
|
||||
|
||||
### System Flags (`sys.*`)
|
||||
|
||||
Control infrastructure-level capabilities. Typically not overridable. Changing these may require understanding of downstream effects.
|
||||
|
||||
Examples: `sys.offline-mode`, `sys.analytics`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Overrides
|
||||
|
||||
For deployment-time control, flags can be overridden via environment variables. This is useful for:
|
||||
|
||||
- Enabling experimental features in staging but not production.
|
||||
- Disabling a broken module without a code change.
|
||||
- Per-environment flag profiles in Docker Compose.
|
||||
|
||||
### Convention
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_FLAG_<NORMALIZED_KEY>=true|false
|
||||
```
|
||||
|
||||
The key is normalized by replacing `.` and `-` with `_` and uppercasing:
|
||||
|
||||
| Flag Key | Environment Variable |
|
||||
|---|---|
|
||||
| `module.devize-generator` | `NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR` |
|
||||
| `exp.ai-assistant` | `NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT` |
|
||||
| `sys.offline-mode` | `NEXT_PUBLIC_FLAG_SYS_OFFLINE_MODE` |
|
||||
|
||||
### Priority Order
|
||||
|
||||
```
|
||||
Environment variable > Runtime override (localStorage) > Compiled default
|
||||
```
|
||||
|
||||
Environment variables take the highest priority because they represent a deployment decision that should not be overridden by stale localStorage state.
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
# docker-compose.staging.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
- NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT=true
|
||||
- NEXT_PUBLIC_FLAG_SYS_ANALYTICS=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### 1. Direct Module Import (bypassing flags)
|
||||
|
||||
```typescript
|
||||
// WRONG: This imports the module unconditionally -- it will be in the bundle
|
||||
// regardless of the flag state.
|
||||
import { DevizeGenerator } from '@/modules/devize-generator';
|
||||
```
|
||||
|
||||
Always go through the flag-gated `ModuleLoader` or use `FeatureGate`/`useFeatureFlag`.
|
||||
|
||||
### 2. Checking Flags Outside React
|
||||
|
||||
```typescript
|
||||
// WRONG: This reads the default, not the resolved state with overrides.
|
||||
import { flagDefaults } from '@/config/flags';
|
||||
if (flagDefaults.get('module.pontaj')?.enabled) { ... }
|
||||
```
|
||||
|
||||
Always use `useFeatureFlag()` inside a component or `useFeatureFlagContext()` to get the resolved state.
|
||||
|
||||
### 3. Hardcoding Flag Values
|
||||
|
||||
```typescript
|
||||
// WRONG: This defeats the purpose of having flags.
|
||||
const isAiEnabled = process.env.NODE_ENV === 'production' ? false : true;
|
||||
```
|
||||
|
||||
Use the flag system. If you need environment-specific behavior, use the env var override mechanism.
|
||||
|
||||
### 4. Flag Key Mismatch
|
||||
|
||||
The flag `key` in `flags.ts` must exactly match the `featureFlag` in the module's `config.ts`. A mismatch means the module can never be gated. The registry validation (`validateRegistry`) does not currently check this -- it is enforced by code review and the module development checklist.
|
||||
|
||||
### 5. Non-Overridable Flags in the Admin Panel
|
||||
|
||||
Do not expose `overridable: false` flags in the admin toggle UI. The provider already guards against this, but the UI should also filter them out.
|
||||
|
||||
---
|
||||
|
||||
## Future: Remote Flag Service
|
||||
|
||||
The current system is local-first by design. When a remote flag service is needed (e.g., for per-user rollout, A/B testing, or centralized control), the integration point is the `FeatureFlagProvider`:
|
||||
|
||||
1. On mount, the provider fetches flags from the remote service.
|
||||
2. Remote values are merged into the resolution chain with a priority between env vars and runtime overrides:
|
||||
```
|
||||
Env var > Remote service > Runtime override > Compiled default
|
||||
```
|
||||
3. The provider caches remote flags in localStorage for offline resilience.
|
||||
4. Consumer code (`useFeatureFlag`, `FeatureGate`) requires zero changes.
|
||||
|
||||
The `FeatureFlag` interface and provider API are designed to accommodate this without breaking changes.
|
||||
Reference in New Issue
Block a user