[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:
parent
c91b8e5419
commit
504eb082c8
@ -41,6 +41,7 @@ import { marketDataRouter } from './modules/market-data/index.js';
|
|||||||
import { currencyRouter } from './modules/currency/index.js';
|
import { currencyRouter } from './modules/currency/index.js';
|
||||||
import { auditRouter } from './modules/audit/index.js';
|
import { auditRouter } from './modules/audit/index.js';
|
||||||
import { proxyRoutes } from './modules/proxy/index.js';
|
import { proxyRoutes } from './modules/proxy/index.js';
|
||||||
|
import featureFlagsRouter from './modules/feature-flags/feature-flags.routes.js';
|
||||||
|
|
||||||
// Service clients for health checks
|
// Service clients for health checks
|
||||||
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
|
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
|
||||||
@ -161,6 +162,7 @@ apiRouter.use('/notifications', notificationRouter);
|
|||||||
apiRouter.use('/market-data', marketDataRouter);
|
apiRouter.use('/market-data', marketDataRouter);
|
||||||
apiRouter.use('/currency', currencyRouter);
|
apiRouter.use('/currency', currencyRouter);
|
||||||
apiRouter.use('/audit', auditRouter);
|
apiRouter.use('/audit', auditRouter);
|
||||||
|
apiRouter.use('/feature-flags', featureFlagsRouter); // Sprint 2: Feature Flags
|
||||||
apiRouter.use('/proxy', proxyRoutes); // ARCH-001: Gateway to Python services
|
apiRouter.use('/proxy', proxyRoutes); // ARCH-001: Gateway to Python services
|
||||||
|
|
||||||
// Mount API router
|
// Mount API router
|
||||||
|
|||||||
208
src/modules/feature-flags/feature-flags.controller.ts
Normal file
208
src/modules/feature-flags/feature-flags.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/modules/feature-flags/feature-flags.routes.ts
Normal file
38
src/modules/feature-flags/feature-flags.routes.ts
Normal 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;
|
||||||
447
src/modules/feature-flags/feature-flags.service.ts
Normal file
447
src/modules/feature-flags/feature-flags.service.ts
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user