[Sprint-2] feat: Add Feature Flags module

- Created feature-flags.service.ts with full CRUD + evaluation
- Created feature-flags.controller.ts with 12 endpoints
- Created feature-flags.routes.ts with auth/admin middleware
- Registered routes in main index.ts

Endpoints:
- GET/POST /feature-flags (admin)
- GET /feature-flags/evaluate (user)
- GET /feature-flags/check/:code (user)
- User override management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 15:38:10 -06:00
parent c91b8e5419
commit 504eb082c8
4 changed files with 695 additions and 0 deletions

View File

@ -41,6 +41,7 @@ import { marketDataRouter } from './modules/market-data/index.js';
import { currencyRouter } from './modules/currency/index.js';
import { auditRouter } from './modules/audit/index.js';
import { proxyRoutes } from './modules/proxy/index.js';
import featureFlagsRouter from './modules/feature-flags/feature-flags.routes.js';
// Service clients for health checks
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
@ -161,6 +162,7 @@ apiRouter.use('/notifications', notificationRouter);
apiRouter.use('/market-data', marketDataRouter);
apiRouter.use('/currency', currencyRouter);
apiRouter.use('/audit', auditRouter);
apiRouter.use('/feature-flags', featureFlagsRouter); // Sprint 2: Feature Flags
apiRouter.use('/proxy', proxyRoutes); // ARCH-001: Gateway to Python services
// Mount API router

View File

@ -0,0 +1,208 @@
/**
* Feature Flags Controller
* API endpoints for feature flag management
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
*/
import { Request, Response, NextFunction } from 'express';
import { featureFlagsService } from './feature-flags.service';
// ============================================================================
// Flag Management (Admin)
// ============================================================================
/**
* GET /feature-flags
* List all feature flags
*/
export const getAllFlags = async (_req: Request, res: Response, next: NextFunction) => {
try {
const flags = await featureFlagsService.getAllFlags();
res.json({ success: true, data: flags });
} catch (error) {
next(error);
}
};
/**
* GET /feature-flags/:id
* Get a single flag by ID
*/
export const getFlagById = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const flag = await featureFlagsService.getFlagById(id);
if (!flag) {
return res.status(404).json({ success: false, error: 'Flag not found' });
}
res.json({ success: true, data: flag });
} catch (error) {
next(error);
}
};
/**
* POST /feature-flags
* Create a new flag
*/
export const createFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const flag = await featureFlagsService.createFlag({
...req.body,
createdBy: req.user?.email || 'system',
});
res.status(201).json({ success: true, data: flag });
} catch (error) {
next(error);
}
};
/**
* PUT /feature-flags/:id
* Update a flag
*/
export const updateFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const flag = await featureFlagsService.updateFlag(id, req.body);
res.json({ success: true, data: flag });
} catch (error) {
next(error);
}
};
/**
* DELETE /feature-flags/:id
* Delete a flag
*/
export const deleteFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await featureFlagsService.deleteFlag(id);
res.json({ success: true, message: 'Flag deleted' });
} catch (error) {
next(error);
}
};
/**
* POST /feature-flags/:id/toggle
* Toggle flag status
*/
export const toggleFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { enabled } = req.query;
const flag = await featureFlagsService.toggleFlag(id, enabled === 'true');
res.json({ success: true, data: flag });
} catch (error) {
next(error);
}
};
// ============================================================================
// Flag Evaluation (User-facing)
// ============================================================================
/**
* GET /feature-flags/evaluate/:code
* Evaluate a single flag for the current user
*/
export const evaluateFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const { code } = req.params;
const userId = req.user?.id;
const enabled = await featureFlagsService.evaluateFlag(code, userId);
res.json({ success: true, data: { code, enabled } });
} catch (error) {
next(error);
}
};
/**
* GET /feature-flags/evaluate
* Evaluate all flags for the current user
*/
export const evaluateAllFlags = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.id;
const evaluations = await featureFlagsService.evaluateAllFlags(userId);
res.json({ success: true, data: evaluations });
} catch (error) {
next(error);
}
};
/**
* GET /feature-flags/check/:code
* Quick check if flag is enabled (simplified response)
*/
export const checkFlag = async (req: Request, res: Response, next: NextFunction) => {
try {
const { code } = req.params;
const userId = req.user?.id;
const enabled = await featureFlagsService.checkFlag(code, userId);
res.json({ enabled });
} catch (error) {
next(error);
}
};
// ============================================================================
// User Overrides
// ============================================================================
/**
* GET /feature-flags/user/:userId/overrides
* Get user's flag overrides
*/
export const getUserOverrides = async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId } = req.params;
const overrides = await featureFlagsService.getUserOverrides(userId);
res.json({ success: true, data: overrides });
} catch (error) {
next(error);
}
};
/**
* POST /feature-flags/user/override
* Set a user override
*/
export const setUserOverride = async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId, flagCode, isEnabled, reason, expiresAt } = req.body;
const override = await featureFlagsService.setUserOverride(
userId,
flagCode,
isEnabled,
reason,
expiresAt ? new Date(expiresAt) : undefined
);
res.json({ success: true, data: override });
} catch (error) {
next(error);
}
};
/**
* DELETE /feature-flags/user/:userId/override/:flagCode
* Remove a user override
*/
export const removeUserOverride = async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId, flagCode } = req.params;
await featureFlagsService.removeUserOverride(userId, flagCode);
res.json({ success: true, message: 'Override removed' });
} catch (error) {
next(error);
}
};

View File

@ -0,0 +1,38 @@
/**
* Feature Flags Routes
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
*/
import { Router } from 'express';
import { authMiddleware, adminMiddleware } from '../../core/middleware/auth.middleware';
import * as controller from './feature-flags.controller';
const router = Router();
// ============================================================================
// Public/User Routes (require auth)
// ============================================================================
// Evaluate flags for current user
router.get('/evaluate', authMiddleware, controller.evaluateAllFlags);
router.get('/evaluate/:code', authMiddleware, controller.evaluateFlag);
router.get('/check/:code', authMiddleware, controller.checkFlag);
// ============================================================================
// Admin Routes (require admin role)
// ============================================================================
// Flag CRUD
router.get('/', authMiddleware, adminMiddleware, controller.getAllFlags);
router.get('/:id', authMiddleware, adminMiddleware, controller.getFlagById);
router.post('/', authMiddleware, adminMiddleware, controller.createFlag);
router.put('/:id', authMiddleware, adminMiddleware, controller.updateFlag);
router.delete('/:id', authMiddleware, adminMiddleware, controller.deleteFlag);
router.post('/:id/toggle', authMiddleware, adminMiddleware, controller.toggleFlag);
// User overrides (admin only)
router.get('/user/:userId/overrides', authMiddleware, adminMiddleware, controller.getUserOverrides);
router.post('/user/override', authMiddleware, adminMiddleware, controller.setUserOverride);
router.delete('/user/:userId/override/:flagCode', authMiddleware, adminMiddleware, controller.removeUserOverride);
export default router;

View File

@ -0,0 +1,447 @@
/**
* Feature Flags Service
* Manages feature flags for gradual rollouts and A/B testing
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
*/
import { db } from '../../shared/database';
import { logger } from '../../shared/utils/logger';
// ============================================================================
// Types
// ============================================================================
export type FlagStatus = 'disabled' | 'enabled' | 'percentage';
export type RolloutStage = 'development' | 'beta' | 'production';
export interface FeatureFlag {
id: string;
code: string;
name: string;
description: string | null;
category: string;
status: FlagStatus;
rolloutStage: RolloutStage;
rolloutPercentage: number;
defaultValue: boolean;
targetingRules: TargetingRule[];
metadata: Record<string, unknown>;
tags: string[];
isPermanent: boolean;
expiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
}
export interface TargetingRule {
type: 'plan' | 'role' | 'user_attribute';
operator: 'eq' | 'neq' | 'in' | 'not_in' | 'gt' | 'lt';
attribute?: string;
value?: string;
values?: string[];
}
export interface UserFlagOverride {
id: string;
userId: string;
flagId: string;
isEnabled: boolean;
reason: string | null;
expiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateFlagInput {
code: string;
name: string;
description?: string;
category?: string;
status?: FlagStatus;
rolloutStage?: RolloutStage;
rolloutPercentage?: number;
defaultValue?: boolean;
targetingRules?: TargetingRule[];
metadata?: Record<string, unknown>;
tags?: string[];
isPermanent?: boolean;
expiresAt?: Date;
createdBy?: string;
}
export interface UpdateFlagInput {
name?: string;
description?: string;
category?: string;
status?: FlagStatus;
rolloutStage?: RolloutStage;
rolloutPercentage?: number;
defaultValue?: boolean;
targetingRules?: TargetingRule[];
metadata?: Record<string, unknown>;
tags?: string[];
isPermanent?: boolean;
expiresAt?: Date | null;
}
export interface FlagEvaluation {
flagCode: string;
enabled: boolean;
reason: string;
}
// ============================================================================
// Database Row Types
// ============================================================================
interface FlagRow {
id: string;
code: string;
name: string;
description: string | null;
category: string;
status: string;
rollout_stage: string;
rollout_percentage: number;
default_value: boolean;
targeting_rules: string;
metadata: string;
tags: string;
is_permanent: boolean;
expires_at: string | null;
created_at: string;
updated_at: string;
created_by: string | null;
}
interface UserFlagRow {
id: string;
user_id: string;
flag_id: string;
is_enabled: boolean;
reason: string | null;
expires_at: string | null;
created_at: string;
updated_at: string;
}
// ============================================================================
// Service
// ============================================================================
class FeatureFlagsService {
/**
* Get all feature flags
*/
async getAllFlags(): Promise<FeatureFlag[]> {
const result = await db.query<FlagRow>(
`SELECT * FROM feature_flags.flags ORDER BY category, name`
);
return result.rows.map(this.transformFlag);
}
/**
* Get flag by ID
*/
async getFlagById(id: string): Promise<FeatureFlag | null> {
const result = await db.query<FlagRow>(
`SELECT * FROM feature_flags.flags WHERE id = $1`,
[id]
);
return result.rows[0] ? this.transformFlag(result.rows[0]) : null;
}
/**
* Get flag by code
*/
async getFlagByCode(code: string): Promise<FeatureFlag | null> {
const result = await db.query<FlagRow>(
`SELECT * FROM feature_flags.flags WHERE code = $1`,
[code]
);
return result.rows[0] ? this.transformFlag(result.rows[0]) : null;
}
/**
* Create a new feature flag
*/
async createFlag(input: CreateFlagInput): Promise<FeatureFlag> {
const {
code,
name,
description,
category = 'general',
status = 'disabled',
rolloutStage = 'development',
rolloutPercentage = 0,
defaultValue = false,
targetingRules = [],
metadata = {},
tags = [],
isPermanent = false,
expiresAt,
createdBy,
} = input;
const result = await db.query<FlagRow>(
`INSERT INTO feature_flags.flags (
code, name, description, category, status, rollout_stage,
rollout_percentage, default_value, targeting_rules, metadata,
tags, is_permanent, expires_at, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`,
[
code,
name,
description || null,
category,
status,
rolloutStage,
rolloutPercentage,
defaultValue,
JSON.stringify(targetingRules),
JSON.stringify(metadata),
JSON.stringify(tags),
isPermanent,
expiresAt || null,
createdBy || null,
]
);
logger.info('[FeatureFlagsService] Flag created:', { code, status });
return this.transformFlag(result.rows[0]);
}
/**
* Update a feature flag
*/
async updateFlag(id: string, input: UpdateFlagInput): Promise<FeatureFlag> {
const updates: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (input.name !== undefined) {
updates.push(`name = $${paramIndex++}`);
values.push(input.name);
}
if (input.description !== undefined) {
updates.push(`description = $${paramIndex++}`);
values.push(input.description);
}
if (input.category !== undefined) {
updates.push(`category = $${paramIndex++}`);
values.push(input.category);
}
if (input.status !== undefined) {
updates.push(`status = $${paramIndex++}`);
values.push(input.status);
}
if (input.rolloutStage !== undefined) {
updates.push(`rollout_stage = $${paramIndex++}`);
values.push(input.rolloutStage);
}
if (input.rolloutPercentage !== undefined) {
updates.push(`rollout_percentage = $${paramIndex++}`);
values.push(input.rolloutPercentage);
}
if (input.defaultValue !== undefined) {
updates.push(`default_value = $${paramIndex++}`);
values.push(input.defaultValue);
}
if (input.targetingRules !== undefined) {
updates.push(`targeting_rules = $${paramIndex++}`);
values.push(JSON.stringify(input.targetingRules));
}
if (input.metadata !== undefined) {
updates.push(`metadata = $${paramIndex++}`);
values.push(JSON.stringify(input.metadata));
}
if (input.tags !== undefined) {
updates.push(`tags = $${paramIndex++}`);
values.push(JSON.stringify(input.tags));
}
if (input.isPermanent !== undefined) {
updates.push(`is_permanent = $${paramIndex++}`);
values.push(input.isPermanent);
}
if (input.expiresAt !== undefined) {
updates.push(`expires_at = $${paramIndex++}`);
values.push(input.expiresAt);
}
values.push(id);
const result = await db.query<FlagRow>(
`UPDATE feature_flags.flags SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Flag not found');
}
logger.info('[FeatureFlagsService] Flag updated:', { id });
return this.transformFlag(result.rows[0]);
}
/**
* Delete a feature flag
*/
async deleteFlag(id: string): Promise<void> {
const result = await db.query(
`DELETE FROM feature_flags.flags WHERE id = $1 AND is_permanent = false`,
[id]
);
if (result.rowCount === 0) {
throw new Error('Flag not found or is permanent');
}
logger.info('[FeatureFlagsService] Flag deleted:', { id });
}
/**
* Toggle flag status
*/
async toggleFlag(id: string, enabled: boolean): Promise<FeatureFlag> {
return this.updateFlag(id, { status: enabled ? 'enabled' : 'disabled' });
}
/**
* Evaluate a flag for a user using database function
*/
async evaluateFlag(code: string, userId?: string): Promise<boolean> {
const result = await db.query<{ evaluate_flag: boolean }>(
`SELECT feature_flags.evaluate_flag($1, $2) as evaluate_flag`,
[code, userId || null]
);
return result.rows[0]?.evaluate_flag || false;
}
/**
* Evaluate all flags for a user
*/
async evaluateAllFlags(userId?: string): Promise<FlagEvaluation[]> {
const flags = await this.getAllFlags();
const evaluations: FlagEvaluation[] = [];
for (const flag of flags) {
const enabled = await this.evaluateFlag(flag.code, userId);
evaluations.push({
flagCode: flag.code,
enabled,
reason: flag.status === 'percentage' ? 'percentage' : 'default',
});
}
return evaluations;
}
/**
* Quick check if flag is enabled (cached in memory)
*/
async checkFlag(code: string, userId?: string): Promise<boolean> {
return this.evaluateFlag(code, userId);
}
/**
* Set user override
*/
async setUserOverride(
userId: string,
flagCode: string,
isEnabled: boolean,
reason?: string,
expiresAt?: Date
): Promise<UserFlagOverride> {
const flag = await this.getFlagByCode(flagCode);
if (!flag) {
throw new Error('Flag not found');
}
const result = await db.query<UserFlagRow>(
`INSERT INTO feature_flags.user_flags (user_id, flag_id, is_enabled, reason, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, flag_id) DO UPDATE SET
is_enabled = EXCLUDED.is_enabled,
reason = EXCLUDED.reason,
expires_at = EXCLUDED.expires_at,
updated_at = NOW()
RETURNING *`,
[userId, flag.id, isEnabled, reason || null, expiresAt || null]
);
logger.info('[FeatureFlagsService] User override set:', { userId, flagCode, isEnabled });
return this.transformUserFlag(result.rows[0]);
}
/**
* Remove user override
*/
async removeUserOverride(userId: string, flagCode: string): Promise<void> {
const flag = await this.getFlagByCode(flagCode);
if (!flag) {
throw new Error('Flag not found');
}
await db.query(
`DELETE FROM feature_flags.user_flags WHERE user_id = $1 AND flag_id = $2`,
[userId, flag.id]
);
logger.info('[FeatureFlagsService] User override removed:', { userId, flagCode });
}
/**
* Get user overrides
*/
async getUserOverrides(userId: string): Promise<UserFlagOverride[]> {
const result = await db.query<UserFlagRow>(
`SELECT * FROM feature_flags.user_flags WHERE user_id = $1`,
[userId]
);
return result.rows.map(this.transformUserFlag);
}
// ============================================================================
// Transform Functions
// ============================================================================
private transformFlag(row: FlagRow): FeatureFlag {
return {
id: row.id,
code: row.code,
name: row.name,
description: row.description,
category: row.category,
status: row.status as FlagStatus,
rolloutStage: row.rollout_stage as RolloutStage,
rolloutPercentage: row.rollout_percentage,
defaultValue: row.default_value,
targetingRules: JSON.parse(row.targeting_rules || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
tags: JSON.parse(row.tags || '[]'),
isPermanent: row.is_permanent,
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
createdBy: row.created_by,
};
}
private transformUserFlag(row: UserFlagRow): UserFlagOverride {
return {
id: row.id,
userId: row.user_id,
flagId: row.flag_id,
isEnabled: row.is_enabled,
reason: row.reason,
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
}
}
export const featureFlagsService = new FeatureFlagsService();