diff --git a/src/index.ts b/src/index.ts index b576ee9..1c43890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..c47bdfa --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -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); + } +}; diff --git a/src/modules/feature-flags/feature-flags.routes.ts b/src/modules/feature-flags/feature-flags.routes.ts new file mode 100644 index 0000000..e254be9 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.routes.ts @@ -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; diff --git a/src/modules/feature-flags/feature-flags.service.ts b/src/modules/feature-flags/feature-flags.service.ts new file mode 100644 index 0000000..af42f46 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.service.ts @@ -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; + 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; + 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; + 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 { + const result = await db.query( + `SELECT * FROM feature_flags.flags ORDER BY category, name` + ); + return result.rows.map(this.transformFlag); + } + + /** + * Get flag by ID + */ + async getFlagById(id: string): Promise { + const result = await db.query( + `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 { + const result = await db.query( + `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 { + 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( + `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 { + 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( + `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 { + 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 { + return this.updateFlag(id, { status: enabled ? 'enabled' : 'disabled' }); + } + + /** + * Evaluate a flag for a user using database function + */ + async evaluateFlag(code: string, userId?: string): Promise { + 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 { + 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 { + return this.evaluateFlag(code, userId); + } + + /** + * Set user override + */ + async setUserOverride( + userId: string, + flagCode: string, + isEnabled: boolean, + reason?: string, + expiresAt?: Date + ): Promise { + const flag = await this.getFlagByCode(flagCode); + if (!flag) { + throw new Error('Flag not found'); + } + + const result = await db.query( + `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 { + 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 { + const result = await db.query( + `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();